diff --git a/docs/testing/HORIZON_JOURNEY.md b/docs/testing/HORIZON_JOURNEY.md index 33367bb76..ca9cbd146 100644 --- a/docs/testing/HORIZON_JOURNEY.md +++ b/docs/testing/HORIZON_JOURNEY.md @@ -691,6 +691,8 @@ and the step title overstated it.** The same URL also silently filters the Inbox to `0 IN QUEUE` with no filter chip (finding **H-04**). +**Addendum 2026-09-05 (PR #2580).** The Paper board can no longer produce this deep link: `+ CAPTURE` now pushes `boardId` only, and the Inbox scope chip names only the board, so the URL recorded above is board-scoped from here on; honouring a column end to end remains open on #1984. + --- ### Step 21 — Today view: plan, note, seal @@ -834,7 +836,7 @@ bindings all work, the single-letter navigation set does not. | 3b-i | Inbox composer — label | yes | **BROKEN** | discarded in `PaperInboxView.vue:118-131`; absent from both DTOs | — | | 3b-ii | Inbox composer — due date | yes | **BROKEN** | discarded in `PaperInboxView.vue:118-131`; absent from both DTOs | — | | 3c | Board-scoped `+ CAPTURE` deep link | yes | PASS | deep-link preselects the board | — | -| 3d | **Column**-scoped capture | yes | **BROKEN** | `columnId` in URL, read by nothing; no column on either DTO | — | +| 3d | **Column**-scoped capture | yes | **BROKEN** | `columnId` in URL, read by nothing; no column on either DTO | 2026-09-05 (PR #2580): the link is board-only now — the untruthful copy was removed; the column branch stays open on #1984 | | 4 | Transcript capture + extraction | yes | **PASS** | 6/6 items, evidence spans | requires a live provider — §2.3.1; **not replayable on a default checkout** | | 5 | Inbox triage accept (with board) | yes | PASS | `POST …/triage → 202` | — | | 5b | Accept with no board | yes | **PASS (fixed)** | button correctly `disabled` | **#1944 fixed** | diff --git a/frontend/taskdeck-web/src/locales/en/boardDetail.ts b/frontend/taskdeck-web/src/locales/en/boardDetail.ts index 35452b07c..d02fd1362 100644 --- a/frontend/taskdeck-web/src/locales/en/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/en/boardDetail.ts @@ -28,7 +28,17 @@ export default { cancel: 'Cancel', error: 'Could not add the card. Please try again.', capture: '+ capture', - captureAria: 'Capture a note into Inbox for {column}', + // The accessible name promises exactly what the control does (#1984 + // finding 2), and it carries NO column. + // + // It used to read "for {column}", which a screen-reader user hears as a + // destination or a filter. Naming the column at all — even as "from + // {column}" — is still wrong here: every column's capture control pushes + // `{ boardId }` and nothing else, the same as the board-level "Capture + // here", so per-column names would announce a distinction the system does + // not make. `addAria` above keeps its `{column}` because that control + // really does differ per column. + captureAria: "Capture a note into this board's Inbox", }, column: { settings: 'Column settings', diff --git a/frontend/taskdeck-web/src/locales/en/inbox.ts b/frontend/taskdeck-web/src/locales/en/inbox.ts index 05fdc4b6d..e6ed9b4ed 100644 --- a/frontend/taskdeck-web/src/locales/en/inbox.ts +++ b/frontend/taskdeck-web/src/locales/en/inbox.ts @@ -120,9 +120,13 @@ export default { selectedBoard: 'the selected board', submit: 'Capture', }, + // `scope.board` is the applied-filter chip and the `{scope}` inside + // `empty.scoped`, so it may name only what the list request narrowed by. A + // `boardAndColumn` form was removed with #1984 finding 2: the Inbox list is + // fetched with a boardId and no column key, so naming a column here told the + // reader a filter had been applied that never was. scope: { board: 'Board: {board}', - boardAndColumn: 'Board: {board} · Column: {column}', clear: 'Show all captures', }, empty: { diff --git a/frontend/taskdeck-web/src/locales/es/boardDetail.ts b/frontend/taskdeck-web/src/locales/es/boardDetail.ts index b41e3d6df..86b2e10c3 100644 --- a/frontend/taskdeck-web/src/locales/es/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/es/boardDetail.ts @@ -20,7 +20,11 @@ export default { cancel: 'Cancelar', error: 'No se pudo añadir la tarjeta. Inténtalo de nuevo.', capture: '+ nota', - captureAria: 'Toma una nota en Inbox para {column}', + // Sin columna en el nombre accesible (#1984, hallazgo 2): el control de + // cada columna abre el Inbox del tablero y nada más, así que un nombre por + // columna anunciaría una distinción que no existe. `addAria` conserva + // `{column}` porque ese control sí cambia según la columna. + captureAria: 'Toma una nota en el Inbox de este tablero', }, column: { settings: 'Ajustes de la columna', diff --git a/frontend/taskdeck-web/src/locales/es/inbox.ts b/frontend/taskdeck-web/src/locales/es/inbox.ts index e0f9391ac..c655ffe50 100644 --- a/frontend/taskdeck-web/src/locales/es/inbox.ts +++ b/frontend/taskdeck-web/src/locales/es/inbox.ts @@ -89,9 +89,11 @@ export default { selectedBoard: 'el tablero seleccionado', submit: 'Capturar', }, + // `boardAndColumn` se eliminó con #1984 (hallazgo 2): la lista del Inbox se + // solicita por tablero y sin columna, así que nombrar una columna aquí + // declaraba un filtro que nunca se aplicó. scope: { board: 'Tablero: {board}', - boardAndColumn: 'Tablero: {board} · Columna: {column}', clear: 'Mostrar todas las capturas', }, empty: { diff --git a/frontend/taskdeck-web/src/locales/it/boardDetail.ts b/frontend/taskdeck-web/src/locales/it/boardDetail.ts index 02c048e46..bf1846639 100644 --- a/frontend/taskdeck-web/src/locales/it/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/it/boardDetail.ts @@ -20,7 +20,11 @@ export default { cancel: 'Annulla', error: 'Non è stato possibile aggiungere la scheda. Riprova.', capture: '+ appunto', - captureAria: 'Prendi un appunto in Inbox per {column}', + // Nessuna colonna nel nome accessibile (#1984, constatazione 2): il + // controllo di ogni colonna apre l'Inbox della bacheca e nient'altro, quindi + // nomi diversi per colonna annuncerebbero una distinzione inesistente. + // `addAria` mantiene `{column}` perché quel controllo cambia davvero. + captureAria: 'Prendi un appunto nell’Inbox di questa bacheca', }, column: { settings: 'Impostazioni colonna', diff --git a/frontend/taskdeck-web/src/locales/it/inbox.ts b/frontend/taskdeck-web/src/locales/it/inbox.ts index 235d42a9b..8c33481f6 100644 --- a/frontend/taskdeck-web/src/locales/it/inbox.ts +++ b/frontend/taskdeck-web/src/locales/it/inbox.ts @@ -87,9 +87,11 @@ export default { selectedBoard: 'la bacheca selezionata', submit: 'Cattura', }, + // `boardAndColumn` rimosso con #1984 (constatazione 2): l'elenco Inbox viene + // richiesto per bacheca e senza colonna, quindi nominare una colonna qui + // dichiarava un filtro mai applicato. scope: { board: 'Bacheca: {board}', - boardAndColumn: 'Bacheca: {board} · Colonna: {column}', clear: 'Mostra tutte le catture', }, empty: { diff --git a/frontend/taskdeck-web/src/tests/composables/useInboxOrchestrator.spec.ts b/frontend/taskdeck-web/src/tests/composables/useInboxOrchestrator.spec.ts index e7ea76b6a..c53a19c0e 100644 --- a/frontend/taskdeck-web/src/tests/composables/useInboxOrchestrator.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useInboxOrchestrator.spec.ts @@ -796,6 +796,17 @@ describe('useInboxOrchestrator', () => { ) }) + // #1984 finding 2: a hand-written or bookmarked URL can still carry a + // column. The list request stays board-only, which is why nothing on the + // Inbox may present the column as an applied filter. The chip side of this + // contract is pinned in `views/paper/inbox/PaperInboxScopeTruth.spec.ts`. + it('ignores a columnId in the route and still requests the board only', async () => { + mockRoute.query = { boardId: 'board-1', columnId: 'col-ready' } + const orch = createOrchestrator() + await orch.loadInbox() + expect(mockCaptureStore.fetchItems).toHaveBeenCalledWith({ limit: 200, boardId: 'board-1' }) + }) + it('calls fetchItems without boardId when none active', async () => { mockRoute.query = {} const orch = createOrchestrator() diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts index 1abe91739..3f824bfaa 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts @@ -909,14 +909,46 @@ describe('PaperBoardView — visible keyboard column selection', () => { }) describe('PaperBoardView — the capture lane still exists', () => { - it('routes "+ capture" to the column-scoped Inbox composer', async () => { + // #1984 finding 2: this pin used to require `columnId` in the pushed query. + // Nothing consumes it — `CaptureListQuery` has no column key, the create DTO + // has no `ColumnId`, and triage targets the board's default column — so the + // only thing it did was make the Inbox chip claim a filter that was never + // applied. The board scope is the whole truth this control can carry. + it('routes "+ capture" to the board-scoped Inbox, carrying no column the Inbox cannot honour', async () => { const wrapper = mountView() await wrapper.findAll('[data-testid="paper-column-capture"]')[1]!.trigger('click') expect(routerMock.push).toHaveBeenCalledWith({ name: 'workspace-inbox', - query: { boardId: 'board-1', columnId: 'col-today' }, + query: { boardId: 'board-1' }, }) + expect(routerMock.push).not.toHaveBeenCalledWith( + expect.objectContaining({ query: expect.objectContaining({ columnId: 'col-today' }) }), + ) + }) + + /** + * Every column's capture control now does the SAME thing — it pushes + * `{ boardId }`, exactly as the board-level "Capture here" does — so they all + * carry the same accessible name. Naming them per column ("from Backlog", + * "from Today", "from Done") would announce a distinction the system does not + * make, which is the same class of untruth #1984 finding 2 is about. Contrast + * `addAria`, which stays per column because that control really does differ. + */ + it('gives every column capture control the same board-level accessible name', () => { + const wrapper = mountView() + const captures = wrapper.findAll('[data-testid="paper-column-capture"]') + + expect(captures).toHaveLength(3) + const names = captures.map((capture) => capture.attributes('aria-label')) + expect(names).toEqual([ + "Capture a note into this board's Inbox", + "Capture a note into this board's Inbox", + "Capture a note into this board's Inbox", + ]) + // The per-column control that genuinely differs keeps its per-column name. + expect(wrapper.findAll('[data-testid="paper-column-add-card"]')[1]?.attributes('aria-label')) + .toBe('Add a card to Today') }) }) diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts index 6539d7133..b10f6acee 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts @@ -327,7 +327,12 @@ describe('PaperInboxView', () => { wrapper.unmount() }) - it('discloses the board and column scope, then clears it without reloading', async () => { + // #1984 finding 2: this pin used to require "Column: Ready" in the chip while + // the list request was board-only, so a green suite defended the untruth. The + // chip names the applied filter and nothing else. The chip and the request are + // asserted together in `inbox/PaperInboxScopeTruth.spec.ts`, which mounts this + // view over the real orchestrator; here the orchestrator is a stub. + it('discloses only the board scope the list request applies, then clears it without reloading', async () => { orchestratorState.activeBoardId.value = 'board-active' orchestratorState.activeColumnId.value = 'column-ready' orchestratorState.activeBoardName.value = 'Payments API Migration' @@ -340,7 +345,8 @@ describe('PaperInboxView', () => { const wrapper = mount(PaperInboxView) expect(wrapper.find('[data-testid="paper-scope-disclosure"]').text()).toContain('Board: Payments API Migration') - expect(wrapper.find('[data-testid="paper-scope-disclosure"]').text()).toContain('Column: Ready') + expect(wrapper.find('[data-testid="paper-scope-disclosure"]').text()).not.toContain('Column') + expect(wrapper.find('[data-testid="paper-scope-disclosure"]').text()).not.toContain('Ready') await wrapper.find('[data-testid="paper-scope-clear"]').trigger('click') await wrapper.vm.$nextTick() @@ -361,6 +367,21 @@ describe('PaperInboxView', () => { expect(orchestratorState.clearScope).toHaveBeenCalledTimes(1) }) + // #1984 finding 2: the scoped empty state interpolates the same label as the + // chip, so a column left in the route used to make it read "No captures in + // Board: X · Column: Y" over a list that was never column-filtered. + it('names only the applied scope in an empty Inbox when the route still carries a column', async () => { + orchestratorState.activeBoardId.value = 'board-active' + orchestratorState.activeColumnId.value = 'column-ready' + orchestratorState.activeBoardName.value = 'Payments API Migration' + orchestratorState.activeColumnName.value = 'Ready' + + const wrapper = mount(PaperInboxView) + expect(wrapper.text()).toContain('No captures in Board: Payments API Migration') + expect(wrapper.text()).not.toContain('Column: Ready') + expect(wrapper.find('[data-testid="paper-triage-clear-scope"]').exists()).toBe(true) + }) + it('toggles between composer and nib when Cmd+; is pressed globally', async () => { const wrapper = mount(PaperInboxView, { attachTo: document.body }) expect(wrapper.attributes('data-variant')).toBe('composer') diff --git a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperInboxScopeTruth.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperInboxScopeTruth.spec.ts new file mode 100644 index 000000000..e0dc4ecee --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperInboxScopeTruth.spec.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { reactive } from 'vue' +import PaperInboxView from '../../../../views/paper/PaperInboxView.vue' +import type { BoardDetail } from '../../../../types/board' + +/** + * The Inbox scope chip and the Inbox list request, asserted in ONE test (#1984 + * finding 2). + * + * Neither existing spec can hold both halves of this truth. + * `PaperInboxView.spec.ts` replaces `useInboxOrchestrator` with a stub, so the + * real `fetchItems` argument never exists there; `useInboxOrchestrator.spec.ts` + * mocks the `vue` module itself, so no component can be mounted there. The + * defect this issue records is exactly the seam between them: the chip named a + * Column filter that the list request never applied. So this spec mounts the + * real view over the real orchestrator with only the route, the capture store, + * the session store and the boards API stubbed, and observes the rendered chip + * and the outgoing request together. + * + * It lives beside the inbox component specs so the region's standing + * verification command (`src/tests/views/paper/inbox`) already covers it. + */ + +const mockRoute = reactive<{ hash: string; query: Record }>({ + hash: '', + query: {}, +}) +const mockRouter = { push: vi.fn(), replace: vi.fn() } + +vi.mock('vue-router', () => ({ + useRoute: () => mockRoute, + useRouter: () => mockRouter, +})) + +const mockCaptureStore = reactive({ + items: [] as Array<{ id: string }>, + detailById: {} as Record, + loadingList: false, + listError: null as string | null, + actionBusyItemId: null as string | null, + triagePollingItemId: null as string | null, + fetchItems: vi.fn<(...args: unknown[]) => Promise>(), + fetchDetail: vi.fn(), + peekDetail: vi.fn(), + cacheDetail: vi.fn(), + createItem: vi.fn(), + triageItem: vi.fn(), + keepItem: vi.fn(), + archiveItem: vi.fn(), + ignoreItem: vi.fn(), + cancelItem: vi.fn(), + batchTriage: vi.fn(), + updateSuggestion: vi.fn(), + pollTriageCompletion: vi.fn(() => () => undefined), + pollBatchTriageCompletion: vi.fn(() => () => undefined), +}) + +vi.mock('../../../../store/captureStore', () => ({ + useCaptureStore: () => mockCaptureStore, +})) + +const mockBoardsApi = vi.hoisted(() => ({ + getBoard: vi.fn<(id: string) => Promise>(), +})) + +vi.mock('../../../../api/boardsApi', () => ({ + boardsApi: mockBoardsApi, +})) + +const mockBoardStore = reactive({ + boards: [] as Array<{ id: string; name: string }>, + fetchBoards: vi.fn<() => Promise>(), +}) + +vi.mock('../../../../store/boardStore', () => ({ + useBoardStore: () => mockBoardStore, +})) + +const mockSessionStore = reactive({ userId: 'user-a' as string | null }) + +vi.mock('../../../../store/sessionStore', () => ({ + useSessionStore: () => mockSessionStore, +})) + +function scopedBoard(): BoardDetail { + const timestamp = '2026-09-04T00:00:00Z' + return { + id: 'board-1', + name: 'Payments API Migration', + description: null, + isArchived: false, + createdAt: timestamp, + updatedAt: timestamp, + columns: [ + { + id: 'col-ready', + boardId: 'board-1', + name: 'Ready', + position: 0, + wipLimit: null, + cardCount: 0, + createdAt: timestamp, + updatedAt: timestamp, + }, + ], + } +} + +/** + * Shallow so the heavy capture/triage children stay stubbed, but with the scope + * disclosure left real: the chip's rendered text is the thing under test. + */ +function mountInbox() { + return mount(PaperInboxView, { + shallow: true, + global: { + stubs: { PaperScopeDisclosure: false }, + }, + }) +} + +describe('Paper Inbox scope truth (#1984)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRoute.hash = '' + mockRoute.query = {} + mockCaptureStore.items = [] + mockCaptureStore.detailById = {} + mockCaptureStore.loadingList = false + mockCaptureStore.listError = null + mockCaptureStore.fetchItems.mockResolvedValue(undefined) + mockBoardsApi.getBoard.mockResolvedValue(scopedBoard()) + mockBoardStore.fetchBoards.mockResolvedValue(undefined) + mockSessionStore.userId = 'user-a' + }) + + it('never names a Column filter in the chip while the list request is board-only', async () => { + mockRoute.query = { boardId: 'board-1', columnId: 'col-ready' } + + const wrapper = mountInbox() + await flushPromises() + + // What the list request actually applies. + expect(mockCaptureStore.fetchItems).toHaveBeenCalledTimes(1) + const listQuery = mockCaptureStore.fetchItems.mock.calls[0]![0] as Record + expect(listQuery).toEqual({ limit: 200, boardId: 'board-1' }) + expect(listQuery).not.toHaveProperty('columnId') + + // What the chip claims it applies. The two must say the same thing. + const chip = wrapper.get('[data-testid="paper-scope-disclosure"]') + expect(chip.text()).toContain('Board: Payments API Migration') + expect(chip.text()).not.toContain('Column') + expect(chip.text()).not.toContain('Ready') + + wrapper.unmount() + }) + + it('keeps the chip board-only when the route carries no column at all', async () => { + mockRoute.query = { boardId: 'board-1' } + + const wrapper = mountInbox() + await flushPromises() + + expect(mockCaptureStore.fetchItems).toHaveBeenCalledWith({ limit: 200, boardId: 'board-1' }) + expect(wrapper.get('[data-testid="paper-scope-disclosure"]').text()).toContain( + 'Board: Payments API Migration', + ) + + wrapper.unmount() + }) +}) diff --git a/frontend/taskdeck-web/src/views/paper/PaperBoardColumn.vue b/frontend/taskdeck-web/src/views/paper/PaperBoardColumn.vue index 722f69887..69dbe00cb 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperBoardColumn.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperBoardColumn.vue @@ -312,7 +312,7 @@ function onCardDragOver(card: Card, e: DragEvent) {