Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/testing/HORIZON_JOURNEY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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** |
Expand Down
12 changes: 11 additions & 1 deletion frontend/taskdeck-web/src/locales/en/boardDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion frontend/taskdeck-web/src/locales/en/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 5 additions & 1 deletion frontend/taskdeck-web/src/locales/es/boardDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion frontend/taskdeck-web/src/locales/es/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 5 additions & 1 deletion frontend/taskdeck-web/src/locales/it/boardDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion frontend/taskdeck-web/src/locales/it/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
25 changes: 23 additions & 2 deletions frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }>({
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<string, unknown>,
loadingList: false,
listError: null as string | null,
actionBusyItemId: null as string | null,
triagePollingItemId: null as string | null,
fetchItems: vi.fn<(...args: unknown[]) => Promise<void>>(),
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<BoardDetail>>(),
}))

vi.mock('../../../../api/boardsApi', () => ({
boardsApi: mockBoardsApi,
}))

const mockBoardStore = reactive({
boards: [] as Array<{ id: string; name: string }>,
fetchBoards: vi.fn<() => Promise<void>>(),
})

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<string, unknown>
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()
})
})
2 changes: 1 addition & 1 deletion frontend/taskdeck-web/src/views/paper/PaperBoardColumn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ function onCardDragOver(card: Card, e: DragEvent) {
<button
type="button"
class="paper-board-column__capture"
:aria-label="t('boardDetail.card.captureAria', { column: column.name })"
:aria-label="t('boardDetail.card.captureAria')"
:data-action="`capture-column-${column.id}`"
data-testid="paper-column-capture"
@click="onCapture"
Expand Down
Loading
Loading