From 29982c039dbe8a70a75ba249648cd101dacb5790 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 4 Sep 2026 23:31:25 +0100 Subject: [PATCH 1/4] fix(inbox): name only the board scope the list request applies The Paper Inbox chip read "Board: {board} - Column: {column}" whenever the route carried a columnId, while loadInboxInternal calls fetchItems({ limit: 200, boardId }) with no column key. The list was never column-filtered, so the chip named a filter that did not exist, and the scoped empty state interpolated the same label. scopeLabel now always renders inbox.scope.board, and the untruthful inbox.scope.boardAndColumn key is removed from en, it and es. The column is not promoted to a second line: CaptureListQuery has no column key, CreateCaptureItemDto has no ColumnId and triage targets the board's default column, so there is no capture destination to disclose either. useInboxOrchestrator is untouched, so activeColumnId and clearScope's delete of query.columnId still handle a hand-written URL. New spec inbox/PaperInboxScopeTruth.spec.ts mounts the view over the real orchestrator and asserts the rendered chip and the outgoing fetchItems argument in one test, which neither existing spec can do. Refs #1984 --- frontend/taskdeck-web/src/locales/en/inbox.ts | 6 +- frontend/taskdeck-web/src/locales/es/inbox.ts | 4 +- frontend/taskdeck-web/src/locales/it/inbox.ts | 4 +- .../composables/useInboxOrchestrator.spec.ts | 11 ++ .../tests/views/paper/PaperInboxView.spec.ts | 25 ++- .../paper/inbox/PaperInboxScopeTruth.spec.ts | 172 ++++++++++++++++++ .../src/views/paper/PaperInboxView.vue | 19 +- 7 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 frontend/taskdeck-web/src/tests/views/paper/inbox/PaperInboxScopeTruth.spec.ts 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/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/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/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/PaperInboxView.vue b/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue index d9c13e469..2dd00c7f4 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue @@ -83,11 +83,9 @@ const { captureStore, items, activeBoardId, - activeColumnId, isArchivedHistory, isScopeReplacement, activeBoardName, - activeColumnName, loadInbox, clearScope, } = useInboxOrchestrator({ @@ -99,11 +97,22 @@ const { // The eyebrow labels them separately — the total is not a queue. const { pendingTriageCount, capturedCount } = useInboxCounts(items) +/** + * The applied filter, and only the applied filter (#1984 finding 2). + * + * This label is the chip AND the scoped empty state's `{scope}`, so anything it + * names is read as something the list was narrowed by. The list request is + * `fetchItems({ limit: 200, boardId })` — board and nothing else — so a column + * must never appear here, even when one is still sitting in the route from an + * older link or a hand-written URL. It is not a capture destination either: + * `CaptureListQuery` has no column key, the create DTO has no `ColumnId`, and + * triage targets the board's default column, so there is no truthful second + * line to promote it to. Honouring a column end-to-end is the open half of + * `#1984` and needs a product ruling first. + */ const scopeLabel = computed(() => { if (!activeBoardId.value) return '' - return activeColumnId.value - ? t('inbox.scope.boardAndColumn', { board: activeBoardName.value, column: activeColumnName.value }) - : t('inbox.scope.board', { board: activeBoardName.value }) + return t('inbox.scope.board', { board: activeBoardName.value }) }) // Read-only inspection of one retained capture in archived history (#1973). From bb35651fa8a8631050f17442bb35bb424655ca2c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 4 Sep 2026 23:31:33 +0100 Subject: [PATCH 2/4] fix(board): stop the column capture control promising column-scoped capture The board column's "+ capture" pushed { boardId, columnId } to the Inbox and named itself "Capture a note into Inbox for {column}". Nothing honours the column: CaptureListQuery has no column key, CreateCaptureItemDto has no ColumnId, and triage targets the board's default column. The pushed columnId only fed the Inbox scope chip, which claimed a filter the list request never applied. The control now pushes the board scope alone, and the accessible name reads "Capture a note into Inbox from {column}" in en, it and es: the destination is the Inbox, and the column is only where the capture was taken from. Refs #1984 --- .../src/locales/en/boardDetail.ts | 6 ++++- .../src/locales/es/boardDetail.ts | 4 +++- .../src/locales/it/boardDetail.ts | 4 +++- .../views/paper/PaperBoardManagement.spec.ts | 22 +++++++++++++++++-- .../src/views/paper/PaperBoardView.vue | 12 +++++++++- 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/frontend/taskdeck-web/src/locales/en/boardDetail.ts b/frontend/taskdeck-web/src/locales/en/boardDetail.ts index 35452b07c..3e5695515 100644 --- a/frontend/taskdeck-web/src/locales/en/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/en/boardDetail.ts @@ -28,7 +28,11 @@ 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). It used to read "for {column}", which a screen-reader user + // hears as a destination or a filter; the note lands in the board's Inbox + // for triage and the column is only where the capture was taken from. + captureAria: 'Capture a note into Inbox from {column}', }, column: { settings: 'Column settings', diff --git a/frontend/taskdeck-web/src/locales/es/boardDetail.ts b/frontend/taskdeck-web/src/locales/es/boardDetail.ts index b41e3d6df..d9f684ede 100644 --- a/frontend/taskdeck-web/src/locales/es/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/es/boardDetail.ts @@ -20,7 +20,9 @@ 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}', + // "desde {column}", no "para {column}" (#1984, hallazgo 2): la nota llega + // al Inbox del tablero; la columna es solo el punto de partida. + captureAria: 'Toma una nota en Inbox desde {column}', }, column: { settings: 'Ajustes de la columna', diff --git a/frontend/taskdeck-web/src/locales/it/boardDetail.ts b/frontend/taskdeck-web/src/locales/it/boardDetail.ts index 02c048e46..fca600d9a 100644 --- a/frontend/taskdeck-web/src/locales/it/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/it/boardDetail.ts @@ -20,7 +20,9 @@ export default { cancel: 'Annulla', error: 'Non è stato possibile aggiungere la scheda. Riprova.', capture: '+ appunto', - captureAria: 'Prendi un appunto in Inbox per {column}', + // "da {column}", non "per {column}" (#1984, constatazione 2): l'appunto + // finisce nell'Inbox della bacheca, la colonna è solo il punto di partenza. + captureAria: 'Prendi un appunto in Inbox da {column}', }, column: { settings: 'Impostazioni colonna', 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..a6cb3a9af 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,32 @@ 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' }) }), + ) + }) + + // The accessible name is the promise a screen-reader user hears before + // pressing the control, so it names where the note lands (the Inbox) and only + // describes the column as the place the capture was taken from. + it('names the capture control by what it does, not by a column filter it cannot apply', () => { + const wrapper = mountView() + const capture = wrapper.findAll('[data-testid="paper-column-capture"]')[1]! + + expect(capture.attributes('aria-label')).toBe('Capture a note into Inbox from Today') }) }) diff --git a/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue b/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue index 1c0b5d4dc..6b0e1d9d9 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue @@ -422,10 +422,20 @@ function guardDirtyNavigation(): boolean | Promise { onBeforeRouteLeave(guardDirtyNavigation) onBeforeRouteUpdate(guardDirtyNavigation) +/** + * The column's "+ capture" control (#1984 finding 2). + * + * It used to push `columnId` alongside `boardId`. Nothing downstream consumes + * it — `CaptureListQuery` has no column key, the create DTO has no `ColumnId`, + * and triage targets the board's default column — so its only effect was to + * make the Inbox scope chip claim a column filter the list request never + * applied. The board scope is the whole truth this control can carry today; + * honouring a column end-to-end is the open half of `#1984`. + */ function openCapture(_column: Column) { void router.push({ name: 'workspace-inbox', - query: { boardId: boardId.value, columnId: _column.id }, + query: { boardId: boardId.value }, }) } From 03ade3b12f2fc22834466699bc36e31e43078862 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 02:29:30 +0100 Subject: [PATCH 3/4] fix(board): give every column capture control one board-level name Review of PR #2580 (MEDIUM). Rewording captureAria to "from {column}" still gave N functionally identical controls N different accessible names. Since openCapture now pushes { boardId } and nothing else, exactly like the board-level "Capture here", a screen-reader user heard "from Backlog" / "from Today" / "from Done" for one behaviour, announcing a distinction the system does not make. captureAria drops its placeholder in en, it and es and states the board-level truth: "Capture a note into this board's Inbox". addAria keeps {column}, because adding a card really does differ per column. The spec now asserts all three controls carry the same name and that addAria still carries its column. Refs #1984 --- .../src/locales/en/boardDetail.ts | 14 ++++++--- .../src/locales/es/boardDetail.ts | 8 +++-- .../src/locales/it/boardDetail.ts | 8 +++-- .../views/paper/PaperBoardManagement.spec.ts | 30 ++++++++++++++----- .../src/views/paper/PaperBoardColumn.vue | 2 +- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/frontend/taskdeck-web/src/locales/en/boardDetail.ts b/frontend/taskdeck-web/src/locales/en/boardDetail.ts index 3e5695515..d02fd1362 100644 --- a/frontend/taskdeck-web/src/locales/en/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/en/boardDetail.ts @@ -29,10 +29,16 @@ export default { error: 'Could not add the card. Please try again.', capture: '+ capture', // The accessible name promises exactly what the control does (#1984 - // finding 2). It used to read "for {column}", which a screen-reader user - // hears as a destination or a filter; the note lands in the board's Inbox - // for triage and the column is only where the capture was taken from. - captureAria: 'Capture a note into Inbox from {column}', + // 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/es/boardDetail.ts b/frontend/taskdeck-web/src/locales/es/boardDetail.ts index d9f684ede..86b2e10c3 100644 --- a/frontend/taskdeck-web/src/locales/es/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/es/boardDetail.ts @@ -20,9 +20,11 @@ export default { cancel: 'Cancelar', error: 'No se pudo añadir la tarjeta. Inténtalo de nuevo.', capture: '+ nota', - // "desde {column}", no "para {column}" (#1984, hallazgo 2): la nota llega - // al Inbox del tablero; la columna es solo el punto de partida. - captureAria: 'Toma una nota en Inbox desde {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/it/boardDetail.ts b/frontend/taskdeck-web/src/locales/it/boardDetail.ts index fca600d9a..bf1846639 100644 --- a/frontend/taskdeck-web/src/locales/it/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/it/boardDetail.ts @@ -20,9 +20,11 @@ export default { cancel: 'Annulla', error: 'Non è stato possibile aggiungere la scheda. Riprova.', capture: '+ appunto', - // "da {column}", non "per {column}" (#1984, constatazione 2): l'appunto - // finisce nell'Inbox della bacheca, la colonna è solo il punto di partenza. - captureAria: 'Prendi un appunto in Inbox da {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/tests/views/paper/PaperBoardManagement.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts index a6cb3a9af..3f824bfaa 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts @@ -928,13 +928,27 @@ describe('PaperBoardView — the capture lane still exists', () => { ) }) - // The accessible name is the promise a screen-reader user hears before - // pressing the control, so it names where the note lands (the Inbox) and only - // describes the column as the place the capture was taken from. - it('names the capture control by what it does, not by a column filter it cannot apply', () => { - const wrapper = mountView() - const capture = wrapper.findAll('[data-testid="paper-column-capture"]')[1]! - - expect(capture.attributes('aria-label')).toBe('Capture a note into Inbox from 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/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) {