diff --git a/frontend/taskdeck-web/src/locales/en/inbox.ts b/frontend/taskdeck-web/src/locales/en/inbox.ts index 10601a69f..b2cc752cd 100644 --- a/frontend/taskdeck-web/src/locales/en/inbox.ts +++ b/frontend/taskdeck-web/src/locales/en/inbox.ts @@ -131,26 +131,29 @@ export default { // capture vocabulary — receipts, draft restoration, the source radios the Nib // shares — while these four labels exist only on the Composer's form. // - // The four `*Aria` names keep their pre-extraction English BYTE FOR BYTE. - // `views/paper/PaperInboxView.spec.ts` and three Playwright specs select - // these controls by their accessible name, so editing the English here is a - // test change and not a copy change. That is also why `labelsAria` and - // `dueAria` do not yet repeat their visible eyebrow first the way - // `boardPicker.triageAria` does (WCAG 2.5.3, the PR #2675 pattern): closing - // that gap needs those selectors migrated to testids first. + // Every `*Aria` name here leads with the field's visible eyebrow and then + // says what the control does (WCAG 2.5.3 label-in-name, the PR #2675 pattern + // the board controls and `boardPicker.triageAria` already follow). The four + // names could only take that shape once the specs stopped selecting these + // controls by accessible name: they now use the composer's `data-testid` + // attributes, so this copy is copy again and not test API. Keep the eyebrow + // and the name in step — `PaperCaptureComposer.spec.ts` asserts the relation, + // not just the strings, and it runs that check in en, it and es, so the rule + // binds a translator in every supported locale and not only in English. // // The placeholders stay placeholders — a hint about the shape of the value, // never the name of the field, which is what the eyebrow and the accessible - // name are for. + // name are for. `bodyPlaceholder` is a sentence and is capitalized; + // `labelsPlaceholder` is a fragment continuing the box and is not. composer: { bodyLabel: 'Body', - bodyAria: 'Capture body', + bodyAria: 'Body: write the text of this capture', bodyPlaceholder: 'The thought, in plain language…', labelsLabel: 'Labels', - labelsAria: 'Add label', + labelsAria: 'Labels: type a label and press Enter to add it', labelsPlaceholder: 'add and press Enter', dueLabel: 'Due (optional)', - dueAria: 'Due date', + dueAria: 'Due (optional): set a due date for this capture', // A statement about the product, not about this draft: attachments are not // stored with a capture at all yet, so it never varies by row or state. attachmentsUnavailable: 'Attachments are not saved with captures yet.', @@ -196,14 +199,18 @@ export default { // different things: the Composer's chooses where a NEW capture will land, // the triage one chooses a board for the capture already in the row. // - // `composerAria` keeps its pre-extraction English exactly — see the note on - // `composer` above; it happens to already lead with the visible label. - // `triageAria` is free of external selectors, so it takes the full PR #2675 - // shape: the visible label first, then what the control does (WCAG 2.5.3). - // The it/es forms mirror the English rather than expanding on it; Romance - // word order puts the head noun first in `composerAria`, which still leaves - // the visible label inside the accessible name. - composerAria: 'Board picker', + // Both take the PR #2675 shape: the visible label first, then what the + // control does (WCAG 2.5.3). They must stay distinguishable by that second + // half alone — a screen-reader user meeting one of them has no other cue + // about which of the two selects is focused. + // + // `composerAria` may not say the capture LANDS on the chosen board. Every + // capture lands in Inbox; the board choice LINKS it so triage can propose + // against that board, and nothing reaches the board without approve and + // execute (ADR-0003). The composer's own footer and `nib.destination*` say + // exactly that, so this name says "linked to for triage" and the triage + // row's keeps "where this capture goes", which is that select's own job. + composerAria: 'Board: choose which board this capture is linked to for triage', triageAria: 'Board: choose where this capture goes', noBoardOption: 'No board · land in inbox', selectPlaceholder: 'Select a board…', diff --git a/frontend/taskdeck-web/src/locales/es/inbox.ts b/frontend/taskdeck-web/src/locales/es/inbox.ts index 5d2f97d31..f36392aac 100644 --- a/frontend/taskdeck-web/src/locales/es/inbox.ts +++ b/frontend/taskdeck-web/src/locales/es/inbox.ts @@ -90,17 +90,28 @@ export default { // Etiquetas de los campos del Composer (#1871). "Texto" para `body`: es el // texto de la captura, igual que en `triage.edit.label`. "Fecha límite" es el // término del glosario para una fecha de vencimiento, como en - // `triage.edit.metadata.dueDate`. El marcador de posición sigue en minúscula - // y no repite el nombre del campo. + // `triage.edit.metadata.dueDate`. + // + // Cada nombre accesible (`*Aria`) empieza por la etiqueta visible del campo y + // después dice qué hace el control (WCAG 2.5.3, el patrón de la PR #2675). Si + // se reescribe una etiqueta visible, hay que reescribir su nombre accesible: + // `PaperCaptureComposer.spec.ts` comprueba la relación, no solo las cadenas, y + // la comprueba también en español, así que romperla aquí pone el test en rojo. + // + // Los marcadores de posición siguen siendo marcadores: una pista sobre la + // FORMA del valor, nunca el nombre del campo, que es lo que aportan la + // etiqueta visible y el nombre accesible. `bodyPlaceholder` es una frase y va + // en mayúscula inicial a propósito; `labelsPlaceholder` es un fragmento que + // continúa el campo y va en minúscula. composer: { bodyLabel: 'Texto', - bodyAria: 'Texto de la captura', + bodyAria: 'Texto: escribe el contenido de esta captura', bodyPlaceholder: 'La idea, en lenguaje sencillo…', labelsLabel: 'Etiquetas', - labelsAria: 'Añadir etiqueta', + labelsAria: 'Etiquetas: escribe una etiqueta y pulsa Enter para añadirla', labelsPlaceholder: 'añade y pulsa Enter', dueLabel: 'Fecha límite (opcional)', - dueAria: 'Fecha límite', + dueAria: 'Fecha límite (opcional): elige cuándo vence esta captura', attachmentsUnavailable: 'Los archivos adjuntos aún no se guardan con las capturas.', }, nib: { @@ -132,12 +143,18 @@ export default { composer: 'Composer', }, boardPicker: { - // `label` encabeza los dos selectores de tablero. `composerAria` sigue al - // inglés ("Board picker"): en español el sustantivo principal va primero y - // "tablero" queda igualmente dentro del nombre accesible. `triageAria` - // lleva la forma completa con la etiqueta visible delante (WCAG 2.5.3). + // `label` encabeza los dos selectores de tablero. Los dos nombres + // accesibles llevan la etiqueta visible delante (WCAG 2.5.3) y solo se + // distinguen por lo que dicen después. + // + // `composerAria` no puede decir que la captura LLEGUE al tablero elegido: + // toda captura llega al Inbox, y el tablero solo la VINCULA para que el + // triage proponga sobre él; nada llega al tablero sin aprobar y ejecutar + // (ADR-0003). Es lo mismo que dicen el pie del Composer y `nib.destination*`, + // así que este nombre dice "se vincula ... para el triage" y el de la fila + // conserva "a dónde va esta captura", que es lo que hace ese selector. label: 'Tablero', - composerAria: 'Selector de tablero', + composerAria: 'Tablero: elige a qué tablero se vincula esta captura para el triage', triageAria: 'Tablero: elige a dónde va esta captura', noBoardOption: 'Sin tablero · llega al Inbox', selectPlaceholder: 'Selecciona un tablero…', diff --git a/frontend/taskdeck-web/src/locales/it/inbox.ts b/frontend/taskdeck-web/src/locales/it/inbox.ts index b9b819821..2b9628772 100644 --- a/frontend/taskdeck-web/src/locales/it/inbox.ts +++ b/frontend/taskdeck-web/src/locales/it/inbox.ts @@ -87,17 +87,29 @@ export default { }, // Etichette dei campi del Composer (#1871). "Testo" per `body`: è il testo // della cattura, come in `triage.edit.label`, e "Corpo" in italiano richiama - // il corpo di un messaggio, non un appunto. Il segnaposto resta un - // suggerimento in minuscolo e non ripete il nome del campo. + // il corpo di un messaggio, non un appunto. + // + // Ogni nome accessibile (`*Aria`) inizia con l'etichetta visibile del campo e + // poi dice cosa fa il controllo (WCAG 2.5.3, il modello della PR #2675). Se + // si riscrive un'etichetta visibile va riscritto anche il suo nome + // accessibile: `PaperCaptureComposer.spec.ts` verifica la relazione, non solo + // le stringhe, e la verifica anche in italiano, quindi romperla qui fa + // fallire il test. + // + // I segnaposto restano segnaposto: un suggerimento sulla FORMA del valore, + // mai il nome del campo, che è quello che danno l'etichetta visibile e il + // nome accessibile. `bodyPlaceholder` è una frase e ha l'iniziale maiuscola + // per scelta; `labelsPlaceholder` è un frammento che prosegue il campo e resta + // in minuscolo. composer: { bodyLabel: 'Testo', - bodyAria: 'Testo della cattura', + bodyAria: 'Testo: scrivi il contenuto di questa cattura', bodyPlaceholder: 'Il pensiero, in parole semplici…', labelsLabel: 'Etichette', - labelsAria: 'Aggiungi etichetta', + labelsAria: 'Etichette: scrivi un’etichetta e premi Enter per aggiungerla', labelsPlaceholder: 'aggiungi e premi Enter', dueLabel: 'Scadenza (facoltativa)', - dueAria: 'Data di scadenza', + dueAria: 'Scadenza (facoltativa): scegli quando scade questa cattura', attachmentsUnavailable: 'Gli allegati non vengono ancora salvati con le catture.', }, nib: { @@ -129,12 +141,19 @@ export default { composer: 'Composer', }, boardPicker: { - // `label` sta sopra entrambi i selettori di bacheca. `composerAria` segue - // l'inglese ("Board picker"): in italiano il nome della testa va prima, e - // "bacheca" resta comunque dentro il nome accessibile. `triageAria` porta - // per intero la forma etichetta-visibile-per-prima (WCAG 2.5.3). + // `label` sta sopra entrambi i selettori di bacheca. Entrambi i nomi + // accessibili portano l'etichetta visibile per prima (WCAG 2.5.3) e si + // distinguono solo per quello che dicono dopo. + // + // `composerAria` non può dire che la cattura ARRIVI sulla bacheca scelta: + // ogni cattura arriva nell'Inbox, e la bacheca la COLLEGA soltanto perché il + // triage possa proporre su di essa; niente arriva alla bacheca senza + // approvare ed eseguire (ADR-0003). È quello che dicono il piè di pagina del + // Composer e `nib.destination*`, quindi questo nome dice "collegare ... per + // il triage" e quello della riga conserva "dove va questa cattura", che è il + // compito di quel selettore. label: 'Bacheca', - composerAria: 'Selettore bacheca', + composerAria: 'Bacheca: scegli a quale bacheca collegare questa cattura per il triage', triageAria: 'Bacheca: scegli dove va questa cattura', noBoardOption: 'Nessuna bacheca · arriva nell’Inbox', selectPlaceholder: 'Seleziona una bacheca…', 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 3ee5e8066..decde85e6 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts @@ -122,8 +122,10 @@ describe('PaperInboxView', () => { it('defaults to the composer variant', () => { const wrapper = mount(PaperInboxView) - // Composer renders a textarea with an aria label "Capture body". - expect(wrapper.find('textarea[aria-label="Capture body"]').exists()).toBe(true) + // The Composer's body field is identified by its own `data-testid`, not by + // its accessible name (#1871): the name is translated copy that WCAG 2.5.3 + // may reword, and a selector reading it turns a copy edit into a test edit. + expect(wrapper.find('textarea[data-testid="paper-composer-body"]').exists()).toBe(true) expect(wrapper.attributes('data-variant')).toBe('composer') }) @@ -138,7 +140,7 @@ describe('PaperInboxView', () => { expect(wrapper.attributes('data-history-mode')).toBe('archived') expect(wrapper.text()).toContain('Archived capture history') expect(wrapper.find('[data-testid="paper-inbox-capture"]').exists()).toBe(false) - expect(wrapper.find('textarea[aria-label="Capture body"]').exists()).toBe(false) + expect(wrapper.find('textarea[data-testid="paper-composer-body"]').exists()).toBe(false) expect(wrapper.find('textarea[aria-label="Quick capture input"]').exists()).toBe(false) expect(wrapper.find('[data-action="accept"]').exists()).toBe(false) expect(wrapper.find('[data-action="reject"]').exists()).toBe(false) @@ -627,7 +629,7 @@ describe('PaperInboxView', () => { it('preserves composer and nib drafts while switching capture variants', async () => { const wrapper = mount(PaperInboxView) const setVariant = (wrapper.vm as unknown as { setVariant: (next: 'nib' | 'composer') => void }).setVariant - const composer = wrapper.find('textarea[aria-label="Capture body"]') + const composer = wrapper.find('[data-testid="paper-composer-body"]') await composer.setValue('Composer draft') setVariant('nib') @@ -654,14 +656,14 @@ describe('PaperInboxView', () => { setVariant('composer') await wrapper.vm.$nextTick() - expect(document.activeElement).toBe(wrapper.find('textarea[aria-label="Capture body"]').element) + expect(document.activeElement).toBe(wrapper.find('[data-testid="paper-composer-body"]').element) wrapper.unmount() }) it('resets the composer draft after capture creation succeeds', async () => { const wrapper = mount(PaperInboxView) - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Ship the inbox fix') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -680,7 +682,7 @@ describe('PaperInboxView', () => { it('sends the composer transcript source through the capture request', async () => { const wrapper = mount(PaperInboxView) await wrapper.find('[data-testid="paper-composer-source-transcript"]').setValue() - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Ana: ship it Friday.') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -709,11 +711,11 @@ describe('PaperInboxView', () => { it('sends composer due date and labels through the capture request', async () => { const wrapper = mount(PaperInboxView) - await wrapper.find('textarea[aria-label="Capture body"]').setValue('Buy milk and gas') - await wrapper.find('input[aria-label="Add label"]').setValue('shopping') - await wrapper.find('input[aria-label="Add label"]').trigger('keydown', { key: 'Enter' }) - await wrapper.find('input[aria-label="Due date"]').setValue('2026-08-23') - await wrapper.find('textarea[aria-label="Capture body"]').trigger('keydown', { key: 'Enter', metaKey: true }) + await wrapper.find('[data-testid="paper-composer-body"]').setValue('Buy milk and gas') + await wrapper.find('[data-testid="paper-composer-label-input"]').setValue('shopping') + await wrapper.find('[data-testid="paper-composer-label-input"]').trigger('keydown', { key: 'Enter' }) + await wrapper.find('[data-testid="paper-composer-due"]').setValue('2026-08-23') + await wrapper.find('[data-testid="paper-composer-body"]').trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() expect(mockCaptureStore.createItem).toHaveBeenCalledWith({ @@ -732,12 +734,12 @@ describe('PaperInboxView', () => { composerRef: { resetDraft: () => void } }).composerRef const resetDraft = vi.spyOn(composer, 'resetDraft') - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Prepare regional report') - await wrapper.find('input[aria-label="Add label"]').setValue('Sales') - await wrapper.find('input[aria-label="Add label"]').trigger('keydown', { key: 'Enter' }) - await wrapper.find('input[aria-label="Due date"]').setValue('2026-08-30') + await wrapper.find('[data-testid="paper-composer-label-input"]').setValue('Sales') + await wrapper.find('[data-testid="paper-composer-label-input"]').trigger('keydown', { key: 'Enter' }) + await wrapper.find('[data-testid="paper-composer-due"]').setValue('2026-08-30') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -757,7 +759,7 @@ describe('PaperInboxView', () => { const wrapper = mount(PaperInboxView) await flushPromises() - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Capture in board context') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -775,8 +777,8 @@ describe('PaperInboxView', () => { const wrapper = mount(PaperInboxView) await flushPromises() - await wrapper.find('select[aria-label="Board picker"]').setValue('') - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + await wrapper.find('select[data-testid="paper-composer-board"]').setValue('') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Capture without board context') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -834,7 +836,7 @@ describe('PaperInboxView', () => { it('preserves the composer draft when capture creation fails', async () => { mockCaptureStore.createItem.mockRejectedValueOnce(new Error('offline')) const wrapper = mount(PaperInboxView) - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Do not lose this draft') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -845,7 +847,7 @@ describe('PaperInboxView', () => { it('associates the composer body exactly while its capture error receipt is mounted', async () => { mockCaptureStore.createItem.mockRejectedValueOnce(new Error('offline')) const wrapper = mount(PaperInboxView) - const textarea = wrapper.get('textarea[aria-label="Capture body"]') + const textarea = wrapper.get('[data-testid="paper-composer-body"]') expect(wrapper.find('[data-testid="paper-inbox-capture-error"]').exists()).toBe(false) expect(textarea.attributes('aria-invalid')).toBeUndefined() @@ -894,7 +896,7 @@ describe('PaperInboxView', () => { })) const wrapper = mount(PaperInboxView) - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Submit this once') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) @@ -1012,7 +1014,7 @@ describe('PaperInboxView', () => { throw new Error('Inbox refresh unavailable') }) const wrapper = mount(PaperInboxView) - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Saved despite the follow-up refresh') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -1437,11 +1439,11 @@ describe('PaperInboxView', () => { const wrapper = mount(PaperInboxView) await flushPromises() - await wrapper.find('textarea[aria-label="Capture body"]').setValue('Do not lose this') - await wrapper.find('select[aria-label="Board picker"]').setValue('board-9') - await wrapper.find('input[aria-label="Add label"]').setValue('ops') - await wrapper.find('input[aria-label="Add label"]').trigger('keydown', { key: 'Enter' }) - await wrapper.find('input[aria-label="Due date"]').setValue('2026-09-02') + await wrapper.find('[data-testid="paper-composer-body"]').setValue('Do not lose this') + await wrapper.find('select[data-testid="paper-composer-board"]').setValue('board-9') + await wrapper.find('[data-testid="paper-composer-label-input"]').setValue('ops') + await wrapper.find('[data-testid="paper-composer-label-input"]').trigger('keydown', { key: 'Enter' }) + await wrapper.find('[data-testid="paper-composer-due"]').setValue('2026-09-02') expireSession() @@ -1463,8 +1465,8 @@ describe('PaperInboxView', () => { const wrapper = mount(PaperInboxView) await flushPromises() - await wrapper.find('textarea[aria-label="Capture body"]').setValue('Do not lose this') - await wrapper.find('input[aria-label="Add label"]').setValue('half-typed') + await wrapper.find('[data-testid="paper-composer-body"]').setValue('Do not lose this') + await wrapper.find('[data-testid="paper-composer-label-input"]').setValue('half-typed') expireSession() @@ -1476,7 +1478,7 @@ describe('PaperInboxView', () => { const restored = mount(PaperInboxView) await flushPromises() expect( - restored.find('input[aria-label="Add label"]').element.value, + restored.find('[data-testid="paper-composer-label-input"]').element.value, ).toBe('half-typed') }) @@ -1486,7 +1488,7 @@ describe('PaperInboxView', () => { config: { method: 'post', url: '/capture' }, }) const wrapper = mount(PaperInboxView) - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Receipt keeper') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -1532,12 +1534,12 @@ describe('PaperInboxView', () => { const wrapper = mount(PaperInboxView) await flushPromises() - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') expect(textarea.element.value).toBe('Survived the redirect') expect( - wrapper.find('select[aria-label="Board picker"]').element.value, + wrapper.find('select[data-testid="paper-composer-board"]').element.value, ).toBe('board-9') - expect(wrapper.find('input[aria-label="Due date"]').element.value).toBe( + expect(wrapper.find('[data-testid="paper-composer-due"]').element.value).toBe( '2026-09-02', ) expect(wrapper.text()).toContain('ops') @@ -1595,7 +1597,7 @@ describe('PaperInboxView', () => { await flushPromises() expect( - wrapper.find('textarea[aria-label="Capture body"]').element.value, + wrapper.find('[data-testid="paper-composer-body"]').element.value, ).toBe('') expect(wrapper.find('[data-testid="paper-inbox-capture-restored"]').exists()).toBe(false) expect(wrapper.find('[data-testid="paper-inbox-capture-error"]').exists()).toBe(false) @@ -1608,7 +1610,7 @@ describe('PaperInboxView', () => { // A stash left behind by an earlier interrupted attempt in this tab. stashCaptureDraft({ userId: 'user-a', variant: 'composer', text: 'Stale interrupted attempt' }) - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Saved for real') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -1638,9 +1640,9 @@ describe('PaperInboxView', () => { const before = mount(PaperInboxView) await flushPromises() - const textarea = before.find('textarea[aria-label="Capture body"]') + const textarea = before.find('[data-testid="paper-composer-body"]') await textarea.setValue('Survives a real 401') - await before.find('select[aria-label="Board picker"]').setValue('board-9') + await before.find('select[data-testid="paper-composer-board"]').setValue('board-9') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -1662,10 +1664,10 @@ describe('PaperInboxView', () => { await flushPromises() expect( - after.find('textarea[aria-label="Capture body"]').element.value, + after.find('[data-testid="paper-composer-body"]').element.value, ).toBe('Survives a real 401') expect( - after.find('select[aria-label="Board picker"]').element.value, + after.find('select[data-testid="paper-composer-board"]').element.value, ).toBe('board-9') expect(after.get('[data-testid="paper-inbox-capture-restored"]').text()).toContain( 'Draft restored.', @@ -1689,7 +1691,7 @@ describe('PaperInboxView', () => { await flushPromises() expect( - wrapper.find('textarea[aria-label="Capture body"]').element.value, + wrapper.find('[data-testid="paper-composer-body"]').element.value, ).toBe('') expect(wrapper.find('[data-testid="paper-inbox-capture-restored"]').exists()).toBe(false) expect(window.sessionStorage.getItem(CAPTURE_DRAFT_STORAGE_KEY)).toBeNull() @@ -1702,7 +1704,7 @@ describe('PaperInboxView', () => { await flushPromises() expect( - owner.find('textarea[aria-label="Capture body"]').element.value, + owner.find('[data-testid="paper-composer-body"]').element.value, ).toBe("A's private thought") owner.unmount() }) @@ -1711,7 +1713,7 @@ describe('PaperInboxView', () => { mockSessionStore.userId = null const wrapper = mount(PaperInboxView) await flushPromises() - await wrapper.find('textarea[aria-label="Capture body"]').setValue('Unowned draft') + await wrapper.find('[data-testid="paper-composer-body"]').setValue('Unowned draft') expireSession() @@ -1751,7 +1753,7 @@ describe('PaperInboxView', () => { 'composer', ) await wrapper.vm.$nextTick() - await wrapper.find('textarea[aria-label="Capture body"]').setValue('Composer text') + await wrapper.find('[data-testid="paper-composer-body"]').setValue('Composer text') expireSession() @@ -1771,7 +1773,7 @@ describe('PaperInboxView', () => { }) const wrapper = mount(PaperInboxView) await flushPromises() - const textarea = wrapper.find('textarea[aria-label="Capture body"]') + const textarea = wrapper.find('[data-testid="paper-composer-body"]') await textarea.setValue('Already saved once') await textarea.trigger('keydown', { key: 'Enter', metaKey: true }) await flushPromises() @@ -1779,7 +1781,7 @@ describe('PaperInboxView', () => { expect(mockCaptureStore.createItem).toHaveBeenCalledTimes(1) expect(window.sessionStorage.getItem(CAPTURE_DRAFT_STORAGE_KEY)).toBeNull() expect( - wrapper.find('textarea[aria-label="Capture body"]').element.value, + wrapper.find('[data-testid="paper-composer-body"]').element.value, ).toBe('') }) @@ -1787,7 +1789,7 @@ describe('PaperInboxView', () => { const removeSpy = vi.spyOn(window, 'removeEventListener') const wrapper = mount(PaperInboxView) await flushPromises() - await wrapper.find('textarea[aria-label="Capture body"]').setValue('Gone with the view') + await wrapper.find('[data-testid="paper-composer-body"]').setValue('Gone with the view') wrapper.unmount() diff --git a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperCaptureComposer.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperCaptureComposer.spec.ts index ce92e89cf..c9f81e97e 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperCaptureComposer.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperCaptureComposer.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' -import { reactive } from 'vue' +import { nextTick, reactive } from 'vue' import PaperCaptureComposer from '../../../../views/paper/inbox/PaperCaptureComposer.vue' import { i18n, type SupportedLocale } from '../../../../i18n' @@ -81,7 +81,7 @@ describe('PaperCaptureComposer', () => { const wrapper = mount(PaperCaptureComposer, { props: { invalid: true, errorId: 'paper-inbox-capture-error' }, }) - const textarea = wrapper.get('textarea[aria-label="Capture body"]') + const textarea = wrapper.get('[data-testid="paper-composer-body"]') expect(textarea.attributes('aria-invalid')).toBe('true') expect(textarea.attributes('aria-describedby')).toBe('paper-inbox-capture-error') @@ -139,24 +139,26 @@ describe('PaperCaptureComposer', () => { * The eyebrows, accessible names and placeholders of the four Composer * fields, in the file's own locale idiom (the `it.each` above, from #2654). * - * The English case is the regression guard: `PaperInboxView.spec.ts` and - * three Playwright specs select these controls by their accessible name, so - * the extraction had to leave the English text byte for byte. The Italian - * case is the one that proves the catalogs reach the DOM at all — every - * assertion in it fails on the pre-#1871 component, which hardcoded English - * in the template regardless of locale. + * The English case is the regression guard, and since the selector-to-testid + * migration in this PR it can assert the names the a11y rule wants rather + * than the pre-extraction English the old selectors froze: every accessible + * name leads with its visible eyebrow and then says what the control does + * (WCAG 2.5.3, the PR #2675 pattern). The Italian case is the one that proves + * the catalogs reach the DOM at all — every assertion in it fails on the + * pre-#1871 component, which hardcoded English in the template regardless of + * locale. * - * Selectors are locale-independent on purpose (tag and class, never the + * Selectors are locale-independent on purpose (testid and class, never the * aria-label being asserted): a selector that reads the string under test * cannot fail when that string is wrong, it just finds nothing. */ function fieldChrome(wrapper: ReturnType) { return { eyebrows: wrapper.findAll('.paper-composer__label .tk-eyebrow').map((node) => node.text()), - body: wrapper.get('textarea'), - board: wrapper.get('select'), - label: wrapper.get('input[type="text"]'), - due: wrapper.get('input[type="date"]'), + body: wrapper.get('[data-testid="paper-composer-body"]'), + board: wrapper.get('[data-testid="paper-composer-board"]'), + label: wrapper.get('[data-testid="paper-composer-label-input"]'), + due: wrapper.get('[data-testid="paper-composer-due"]'), attachments: wrapper.get('[data-testid="paper-composer-attachments-unavailable"]'), } } @@ -166,15 +168,60 @@ describe('PaperCaptureComposer', () => { const chrome = fieldChrome(wrapper) expect(chrome.eyebrows).toEqual(['Body', 'Board', 'Labels', 'Due (optional)']) - expect(chrome.body.attributes('aria-label')).toBe('Capture body') + expect(chrome.body.attributes('aria-label')).toBe('Body: write the text of this capture') expect(chrome.body.attributes('placeholder')).toBe('The thought, in plain language…') - expect(chrome.board.attributes('aria-label')).toBe('Board picker') - expect(chrome.label.attributes('aria-label')).toBe('Add label') + expect(chrome.board.attributes('aria-label')).toBe( + 'Board: choose which board this capture is linked to for triage', + ) + expect(chrome.label.attributes('aria-label')).toBe( + 'Labels: type a label and press Enter to add it', + ) expect(chrome.label.attributes('placeholder')).toBe('add and press Enter') - expect(chrome.due.attributes('aria-label')).toBe('Due date') + expect(chrome.due.attributes('aria-label')).toBe( + 'Due (optional): set a due date for this capture', + ) expect(chrome.attachments.text()).toBe('Attachments are not saved with captures yet.') }) + /** + * WCAG 2.5.3 label-in-name, asserted as a RELATION rather than as four more + * literals: each accessible name must START with the eyebrow rendered above + * its control. Written this way it keeps holding when the copy is reworded + * and it fails on the pre-rewrite names, where `Add label` did not contain + * the visible `Labels` and `Due date` did not contain `Due (optional)`. + * + * It runs in EVERY supported locale, both halves of each pair read off the + * DOM. The rule is a property of the catalog, not of English, and the + * es/it docblocks tell a translator this test holds them to it — Spanish + * composer chrome has no other assertion anywhere, so on the default locale + * alone that promise would have been empty. + */ + it.each(['en', 'it', 'es'] as const)( + 'starts every field accessible name with the visible eyebrow above it in %s', + (locale) => { + const previousLocale = i18n.global.locale.value + try { + i18n.global.locale.value = locale as SupportedLocale + const wrapper = mount(PaperCaptureComposer) + const chrome = fieldChrome(wrapper) + const [bodyEyebrow, boardEyebrow, labelsEyebrow, dueEyebrow] = chrome.eyebrows + + const named = [ + [bodyEyebrow, chrome.body.attributes('aria-label')], + [boardEyebrow, chrome.board.attributes('aria-label')], + [labelsEyebrow, chrome.label.attributes('aria-label')], + [dueEyebrow, chrome.due.attributes('aria-label')], + ] as const + + // Reported as pairs so a failure names the eyebrow AND the name that + // broke the rule, instead of four indistinguishable `false`s. + expect(named.filter(([eyebrow, name]) => !name?.startsWith(`${eyebrow}: `))).toEqual([]) + } finally { + i18n.global.locale.value = previousLocale + } + }, + ) + it('re-renders the field chrome in Italian when the locale switches', () => { const previousLocale = i18n.global.locale.value try { @@ -183,12 +230,20 @@ describe('PaperCaptureComposer', () => { const chrome = fieldChrome(wrapper) expect(chrome.eyebrows).toEqual(['Testo', 'Bacheca', 'Etichette', 'Scadenza (facoltativa)']) - expect(chrome.body.attributes('aria-label')).toBe('Testo della cattura') + expect(chrome.body.attributes('aria-label')).toBe( + 'Testo: scrivi il contenuto di questa cattura', + ) expect(chrome.body.attributes('placeholder')).toBe('Il pensiero, in parole semplici…') - expect(chrome.board.attributes('aria-label')).toBe('Selettore bacheca') - expect(chrome.label.attributes('aria-label')).toBe('Aggiungi etichetta') + expect(chrome.board.attributes('aria-label')).toBe( + 'Bacheca: scegli a quale bacheca collegare questa cattura per il triage', + ) + expect(chrome.label.attributes('aria-label')).toBe( + 'Etichette: scrivi un’etichetta e premi Enter per aggiungerla', + ) expect(chrome.label.attributes('placeholder')).toBe('aggiungi e premi Enter') - expect(chrome.due.attributes('aria-label')).toBe('Data di scadenza') + expect(chrome.due.attributes('aria-label')).toBe( + 'Scadenza (facoltativa): scegli quando scade questa cattura', + ) expect(chrome.attachments.text()).toBe( 'Gli allegati non vengono ancora salvati con le catture.', ) @@ -197,6 +252,33 @@ describe('PaperCaptureComposer', () => { } }) + /** + * The case above sets the locale BEFORE mounting, so it proves first render + * only — a component that read `t()` once into a non-reactive snapshot would + * still pass it. This one mounts in English and switches AFTER, so the + * eyebrows and an accessible name have to change on an already-rendered + * component or the assertion fails. + */ + it('re-renders the field chrome when the locale switches after mount', async () => { + const previousLocale = i18n.global.locale.value + try { + i18n.global.locale.value = 'en' as SupportedLocale + const wrapper = mount(PaperCaptureComposer) + expect(fieldChrome(wrapper).eyebrows).toEqual(['Body', 'Board', 'Labels', 'Due (optional)']) + + i18n.global.locale.value = 'it' as SupportedLocale + await nextTick() + + const chrome = fieldChrome(wrapper) + expect(chrome.eyebrows).toEqual(['Testo', 'Bacheca', 'Etichette', 'Scadenza (facoltativa)']) + expect(chrome.body.attributes('aria-label')).toBe( + 'Testo: scrivi il contenuto di questa cattura', + ) + } finally { + i18n.global.locale.value = previousLocale + } + }) + it('reflects label selections in the submit payload', async () => { const wrapper = mount(PaperCaptureComposer) const labelInput = wrapper.find('input[type="text"]') @@ -258,9 +340,9 @@ describe('PaperCaptureComposer', () => { it('does not submit while creation is already in flight', async () => { const wrapper = mount(PaperCaptureComposer, { props: { submitting: true } }) expect(wrapper.find('textarea').attributes('disabled')).toBeDefined() - expect(wrapper.find('select[aria-label="Board picker"]').attributes('disabled')).toBeDefined() - expect(wrapper.find('input[aria-label="Add label"]').attributes('disabled')).toBeDefined() - expect(wrapper.find('input[aria-label="Due date"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-testid="paper-composer-board"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-testid="paper-composer-label-input"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-testid="paper-composer-due"]').attributes('disabled')).toBeDefined() await wrapper.find('textarea').trigger('keydown', { key: 'Enter', metaKey: true }) @@ -278,7 +360,7 @@ describe('PaperCaptureComposer', () => { ] const wrapper = mount(PaperCaptureComposer) - const options = wrapper.findAll('select[aria-label="Board picker"] option') + const options = wrapper.findAll('[data-testid="paper-composer-board"] option') const readOnly = options.find((option) => option.attributes('value') === 'board-readonly') // Visible, NOT filtered away. @@ -294,7 +376,7 @@ describe('PaperCaptureComposer', () => { const wrapper = mount(PaperCaptureComposer) const option = wrapper - .findAll('select[aria-label="Board picker"] option') + .findAll('[data-testid="paper-composer-board"] option') .find((o) => o.attributes('value') === 'board-alpha') expect(option!.attributes('disabled')).toBeUndefined() @@ -308,7 +390,7 @@ describe('PaperCaptureComposer', () => { const wrapper = mount(PaperCaptureComposer) const option = wrapper - .findAll('select[aria-label="Board picker"] option') + .findAll('[data-testid="paper-composer-board"] option') .find((o) => o.attributes('value') === 'board-alpha') expect(option!.attributes('disabled')).toBeUndefined() diff --git a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts index 7c993c6ba..1d0392960 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -import { reactive } from 'vue' +import { nextTick, reactive } from 'vue' import PaperTriageTable from '../../../../views/paper/inbox/PaperTriageTable.vue' import type { CaptureItemSummary, CaptureStatusValue } from '../../../../types/capture' import { i18n, type SupportedLocale } from '../../../../i18n' @@ -446,6 +446,34 @@ describe('PaperTriageTable', () => { } }) + /** + * The Italian case above sets the locale BEFORE mounting, so it proves first + * render only — a component that read `t()` once into a non-reactive snapshot + * would still pass it. This one mounts in English and switches AFTER, so the + * region name and the board-pick chrome have to change on an already-rendered + * component or the assertion fails. + */ + it('re-renders the region name and the board-pick chrome when the locale switches after mount', async () => { + const previousLocale = i18n.global.locale.value + try { + i18n.global.locale.value = 'en' as SupportedLocale + const wrapper = await openBoardPicker(defaultBoards()) + expect(wrapper.attributes('aria-label')).toBe('Captured items') + + i18n.global.locale.value = 'it' as SupportedLocale + await nextTick() + + expect(wrapper.attributes('aria-label')).toBe('Elementi catturati') + const pick = wrapper.get('[data-testid="capture-board-pick"]') + expect(pick.get('.tk-eyebrow').text()).toBe('Bacheca') + expect(pick.get('select').attributes('aria-label')).toBe( + 'Bacheca: scegli dove va questa cattura', + ) + } finally { + i18n.global.locale.value = previousLocale + } + }) + it('labels the board-pick select with the visible eyebrow first in English', async () => { // WCAG 2.5.3 / the PR #2675 pattern: the accessible name repeats the // visible label before saying what the control does. diff --git a/frontend/taskdeck-web/src/views/paper/inbox/PaperCaptureComposer.vue b/frontend/taskdeck-web/src/views/paper/inbox/PaperCaptureComposer.vue index e00f8ab57..2c8e113fc 100644 --- a/frontend/taskdeck-web/src/views/paper/inbox/PaperCaptureComposer.vue +++ b/frontend/taskdeck-web/src/views/paper/inbox/PaperCaptureComposer.vue @@ -242,6 +242,7 @@ defineExpose({ focus: () => bodyRef.value?.focus(), resetDraft, snapshotDraft, r v-model="body" class="paper-composer__textarea" rows="6" + data-testid="paper-composer-body" :aria-label="t('inbox.composer.bodyAria')" :placeholder="t('inbox.composer.bodyPlaceholder')" :disabled="inputsDisabled" @@ -304,6 +305,7 @@ defineExpose({ focus: () => bodyRef.value?.focus(), resetDraft, snapshotDraft, r