From 9ba55206f23ac5440aad8e9b4c9b7835cefbbb21 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sun, 16 Aug 2026 11:28:09 +0200 Subject: [PATCH] fix(gate-35): give NewsWidget thumbnails a real text alternative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `` in NewsWidget shipped `alt=""`, which is a claim that the image is decorative and that assistive tech should ignore it. It is not decorative: `thumbnailUrl` is the article's own lead image, chosen by the publisher, and a sighted reader gets it for free. That is the shape gate-35 exists to catch — silencing gate-31 img-alt with an empty alt rather than naming the image (WCAG 2.2 AA SC 1.1.1). HOW IT GOT HERE, because it is worth knowing fleet-wide: the component was written with `:alt="''"` — a BOUND empty alt, which gate-35 deliberately does not judge. An eslint 10 autofix (bff6092a, "migrate to eslint 10 + @nextcloud/eslint-config 9") rewrote it to the literal `alt=""`, and that is what the gate reads. The finding is real, and the autofix is what surfaced it. RSS/Atom `media:thumbnail` carries no description of its own, so the best honest alternative available is to say what the image is and which article it belongs to: `Thumbnail for ""`, falling back to `Article thumbnail` when the item has no title — a nameless image is announced by its filename, which is worse than a generic but true one. Three tests assert the attribute THAT REACHES THE DOM, not merely that the helper returns good text: a method producing a fine string is worth nothing if the template never binds it. Both render paths are covered (the linked `<a>` item and the inert `<div>` item, which carried separate `alt=""`s). Verified as a positive control: all three FAIL against the unfixed component with `expected '' to be ...`. Measured with the gate's own script (ConductionNL/.github@main, `scripts/run-hydra-gates.sh --full`), over 85 markup files in scope: before [gate-35] img-alt-empty-only: FAIL — 2 <img alt=""> on semantic-bound src after [gate-35] img-alt-empty-only: PASS Repo failing-gate count 7 -> 6; no other gate verdict changed. NewsWidget vitest 7 -> 10 tests, all passing; eslint and prettier clean. --- .../Widgets/Renderers/NewsWidget.vue | 32 ++++++++- .../Renderers/__tests__/NewsWidget.spec.js | 69 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/components/Widgets/Renderers/NewsWidget.vue b/src/components/Widgets/Renderers/NewsWidget.vue index 24cf65ebe..4308ab062 100644 --- a/src/components/Widgets/Renderers/NewsWidget.vue +++ b/src/components/Widgets/Renderers/NewsWidget.vue @@ -46,7 +46,7 @@ v-if="showThumbnails && item.thumbnailUrl" class="news-widget__thumb" :src="item.thumbnailUrl" - alt="" /> + :alt="thumbnailAlt(item)" /> <div class="news-widget__body"> <h4 class="news-widget__title">{{ item.title }}</h4> <!-- Feed summaries carry inline markup REQ-NEWS-005 @@ -78,7 +78,7 @@ v-if="showThumbnails && item.thumbnailUrl" class="news-widget__thumb" :src="item.thumbnailUrl" - alt="" /> + :alt="thumbnailAlt(item)" /> <div class="news-widget__body"> <h4 class="news-widget__title"> {{ item.title }} @@ -422,6 +422,34 @@ export default { return truncateSummaryHtml(raw, this.summaryMaxChars) }, + /** + * Text alternative for an item's lead image (WCAG 2.2 AA SC 1.1.1). + * + * The image was previously silenced with `alt=""`, which claims it is + * decorative. It is not: `thumbnailUrl` is the article's own lead + * image, chosen by the publisher and carrying meaning a sighted + * reader gets for free. RSS/Atom `media:thumbnail` ships no + * description of its own, so the best honest alternative available + * is to say what the image IS and which article it belongs to. + * + * An item with no title still gets a name rather than falling back + * to `alt=""` — a nameless image is announced by its filename. + * + * @param {object} item News item as returned by the items endpoint. + * @return {string} Non-empty text alternative for the thumbnail. + * @spec openspec/specs/news-widget/spec.md + */ + thumbnailAlt(item) { + const title = typeof item?.title === 'string' ? item.title.trim() : '' + if (title === '') { + return t('launchpad', 'Article thumbnail') + } + return t('launchpad', 'Thumbnail for “{title}”').replace( + '{title}', + title, + ) + }, + /** * Format ISO 8601 publication dates per the placement's * `dateFormat` setting. `relative` produces "2 hours ago" style; diff --git a/src/components/Widgets/Renderers/__tests__/NewsWidget.spec.js b/src/components/Widgets/Renderers/__tests__/NewsWidget.spec.js index 170385900..619acf11c 100644 --- a/src/components/Widgets/Renderers/__tests__/NewsWidget.spec.js +++ b/src/components/Widgets/Renderers/__tests__/NewsWidget.spec.js @@ -117,3 +117,72 @@ describe('NewsWidget — summary truncation (REQ-NEWS-004/005)', () => { expect(renderSummary('', 100)).toBe('') }) }) + +/** + * Mount the widget with real items and hand back the rendered wrapper. + * + * @param {Array<object>} items News items to render. + * @return {Promise<object>} The mounted wrapper, after the items are in the DOM. + */ +async function renderItems(items) { + const wrapper = mount(NewsWidget, { + propsData: { + content: { showThumbnails: true, showSummary: false }, + placement: { id: 1 }, + }, + }) + await wrapper.setData({ items, loading: false, hasError: false }) + return wrapper +} + +describe('NewsWidget — thumbnail text alternative (WCAG 2.2 AA SC 1.1.1)', () => { + // The thumbnail is the article's own lead image, not decoration. It + // shipped as `alt=""` — a claim that assistive tech should ignore it — + // which is the "silence the gate rather than name the image" shape. + // These assert the ATTRIBUTE THAT REACHES THE DOM, not just that a + // helper exists: a method returning good text is worth nothing if the + // template never binds it. + + it('gives a linked thumbnail a non-empty alt naming its article', async () => { + const wrapper = await renderItems([ + { + guid: 'a', + title: 'Council approves budget', + link: 'https://example.com/a', + thumbnailUrl: 'https://example.com/a.png', + }, + ]) + + const img = wrapper.find('img.news-widget__thumb') + expect(img.exists()).toBe(true) + expect(img.attributes('alt')).not.toBe('') + expect(img.attributes('alt')).toContain('Council approves budget') + }) + + it('names the thumbnail on the inert (unlinked) item too', async () => { + // The second render path — an item with no `link` renders a <div> + // instead of an <a>, and carried its own separate alt="". + const wrapper = await renderItems([ + { + guid: 'b', + title: 'No link here', + thumbnailUrl: 'https://example.com/b.png', + }, + ]) + + const img = wrapper.find('img.news-widget__thumb') + expect(img.exists()).toBe(true) + expect(img.attributes('alt')).toContain('No link here') + }) + + it('still names an untitled item rather than falling back to alt=""', async () => { + // A nameless image is announced by its filename, which is worse than + // a generic but honest description. + const wrapper = await renderItems([ + { guid: 'c', title: ' ', thumbnailUrl: 'https://example.com/c.png' }, + ]) + + const img = wrapper.find('img.news-widget__thumb') + expect(img.attributes('alt')).toBe('Article thumbnail') + }) +})