From 8dabd9a6df4574444d5b57bb15350dc30b6f4a81 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 03:37:58 +0300 Subject: [PATCH] fix: strengthen visible content accessibility proof --- src/styles/base.css | 4 + tests/browser/keyboard.spec.ts | 4 +- tests/browser/no-javascript.spec.ts | 347 ++++++++++++++++++++++++---- tests/helpers/serve-dist.ts | 36 ++- 4 files changed, 338 insertions(+), 53 deletions(-) diff --git a/src/styles/base.css b/src/styles/base.css index 14a0ec8..0ec33b1 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -50,6 +50,10 @@ select:focus-visible, outline-offset: 3px; } +.portfolio-main:focus-visible { + box-shadow: inset 0 0 0 3px var(--focus); +} + ::selection { background: var(--amber); color: #14100a; diff --git a/tests/browser/keyboard.spec.ts b/tests/browser/keyboard.spec.ts index a1f2317..77d9b89 100644 --- a/tests/browser/keyboard.spec.ts +++ b/tests/browser/keyboard.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '../helpers/serve-dist'; +import { expect, expectInsetFocusIndicator, test } from '../helpers/serve-dist'; const navHrefs = [ '#start', @@ -94,7 +94,7 @@ test('activating the skip link focuses the visible unobscured portfolio content' 'tabindex', '-1', ); - await assertVisibleUnobscuredFocus(page); + await expectInsetFocusIndicator(page, '#portfolio-content'); }); test('explicit section activation scrolls to and focuses its heading', async ({ diff --git a/tests/browser/no-javascript.spec.ts b/tests/browser/no-javascript.spec.ts index 1438408..62f2025 100644 --- a/tests/browser/no-javascript.spec.ts +++ b/tests/browser/no-javascript.spec.ts @@ -1,4 +1,5 @@ -import { expect, test } from '../helpers/serve-dist'; +import type { PortfolioContent } from '@/content/types'; +import { expect, expectInsetFocusIndicator, test } from '../helpers/serve-dist'; test.use({ javaScriptEnabled: false }); @@ -24,15 +25,156 @@ const sectionHeadings = [ 'CONTACT', ] as const; -const visibleTextLeafSelector = 'h1,h2,h3,p,dt,dd,li,figcaption,a'; +const visibleTextLeafSelector = 'h1,h2,h3,p,dt,dd,li,figcaption,a,svg text'; const visibleTextSelector = [ - `.site-header :is(${visibleTextLeafSelector})`, - `.portfolio-main :is(${visibleTextLeafSelector})`, + '.site-header :is(h1,h2,h3,p,dt,dd,li,figcaption,a)', + '.portfolio-main :is(h1,h2,h3,p,dt,dd,li,figcaption,a)', + '.portfolio-main svg text', ].join(','); const normalizedText = (value: string): string => value.replace(/\s+/gu, ' ').trim(); +type TextMultiset = Array<[text: string, count: number]>; + +const textMultiset = (values: readonly string[]): TextMultiset => { + const counts = new Map(); + for (const value of values.map(normalizedText).filter(Boolean)) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + return [...counts].sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); +}; + +const missingText = ( + expected: TextMultiset, + actual: TextMultiset, +): TextMultiset => { + const actualCounts = new Map(actual); + return expected.flatMap(([text, count]) => { + const missing = count - (actualCounts.get(text) ?? 0); + return missing > 0 ? [[text, missing] as [string, number]] : []; + }); +}; + +const portfolioContentFromSnapshot = async ( + page: import('@playwright/test').Page, + rawHtml: string, +): Promise => + page.evaluate((html) => { + const document = new DOMParser().parseFromString(html, 'text/html'); + const snapshot = document.querySelector('#portfolio-inputs'); + if (snapshot?.textContent === null || snapshot?.textContent === undefined) { + throw new Error('Missing #portfolio-inputs snapshot'); + } + return (JSON.parse(snapshot.textContent) as { content: PortfolioContent }) + .content; + }, rawHtml); + +const expectedVisibleTextSegments = ( + content: Readonly, +): string[] => { + const navigationById = new Map( + content.navigation.map((item) => [item.id, item.label]), + ); + const section = ( + id: (typeof sectionIds)[number], + index: number, + body: readonly string[], + ): string[] => { + const label = navigationById.get(id); + if (label === undefined) throw new Error(`Missing navigation label: ${id}`); + const heading = label.toUpperCase(); + return [`${String(index).padStart(2, '0')} / ${heading}`, heading, ...body]; + }; + const projectLinkText = ( + project: PortfolioContent['projects']['selected'][number], + ): string[] => + project.visibility === 'public' && project.url ? [project.title] : []; + + return [ + content.identity.name, + content.identity.role, + ...sectionIds.map((id) => { + const label = navigationById.get(id); + if (label === undefined) + throw new Error(`Missing navigation label: ${id}`); + return label; + }), + ...section('start', 1, [content.identity.valueProposition]), + ...section('profile', 2, [ + content.profile.introduction, + ...content.profile.screeningFacts.flatMap((fact) => [ + fact.label, + fact.value, + ]), + ]), + ...section('experience', 3, [ + ...content.experience.flatMap((record) => [ + record.role, + `${record.organization} · ${record.dates}${record.location === undefined ? '' : ` · ${record.location}`}`, + ...record.highlights, + ]), + content.systemsOperations.title, + content.systemsOperations.summary, + ...content.systemsOperations.evidence, + ]), + ...section('infrastructure', 4, [ + content.infrastructure.title, + content.infrastructure.summary, + ...content.infrastructure.bands.map((band) => band.title), + content.infrastructure.title, + ...content.infrastructure.bands.map( + (band) => `${band.title}: ${band.summary}`, + ), + ]), + ...section( + 'selected-builds', + 5, + content.projects.selected.flatMap((project) => [ + project.title, + project.summary, + 'Problem', + project.problem, + 'Contribution', + project.contribution, + 'Technical decisions', + ...project.technicalDecisions, + 'Outcome', + project.outcome, + ...projectLinkText(project), + ]), + ), + ...section( + 'additional-work', + 6, + content.projects.additional.flatMap((project) => [ + project.title, + project.summary, + project.contribution, + project.outcome, + ...projectLinkText(project), + ]), + ), + ...section( + 'toolkit', + 7, + content.toolkit.flatMap((group) => [ + group.title, + ...group.technologies.map((technology) => technology.name), + ]), + ), + ...section('contact', 8, [ + content.identity.name, + content.identity.role, + content.contact.email, + content.contact.cvMessage, + ...content.contact.social.map((link) => link.service), + ]), + ]; +}; + const rawVisibleTextSegments = async ( page: import('@playwright/test').Page, rawHtml: string, @@ -40,11 +182,51 @@ const rawVisibleTextSegments = async ( page.evaluate( ({ html, leafSelector, rootSelector }) => { const document = new DOMParser().parseFromString(html, 'text/html'); - return [...document.querySelectorAll(rootSelector)] + const isHidden = (element: Element): boolean => { + for ( + let current: Element | null = element; + current; + current = current.parentElement + ) { + if ( + current.matches('template,script,style,noscript') || + current.hasAttribute('hidden') || + current.hasAttribute('inert') + ) { + return true; + } + const ariaHidden = current.getAttribute('aria-hidden'); + if ( + ariaHidden !== null && + ariaHidden.trim().toLowerCase() !== 'false' + ) { + return true; + } + const hiddenClass = + current.getAttribute('class')?.toLowerCase() ?? ''; + if ( + /(?:^|\s|-)print-only(?:\s|-|$)|print-portfolio-label|visually-hidden|sr-only|screen-reader-only/u.test( + hiddenClass, + ) + ) { + return true; + } + const inlineStyle = + current.getAttribute('style')?.toLowerCase() ?? ''; + if ( + /(?:^|;)\s*display\s*:\s*none\b/u.test(inlineStyle) || + /(?:^|;)\s*visibility\s*:\s*hidden\b/u.test(inlineStyle) || + /(?:^|;)\s*opacity\s*:\s*0(?:[;\s]|$)/u.test(inlineStyle) + ) { + return true; + } + } + return false; + }; + return [...document.querySelectorAll(rootSelector)] .filter( (element) => - element.closest('[aria-hidden="true"]') === null && - element.querySelector(leafSelector) === null, + !isHidden(element) && element.querySelector(leafSelector) === null, ) .map((element) => (element.textContent ?? '').replace(/\s+/gu, ' ').trim(), @@ -65,54 +247,55 @@ const browserVisibleTextSegments = async ( (elements, leafSelector) => elements .filter((element) => { - const htmlElement = element as HTMLElement; - const style = getComputedStyle(htmlElement); + for ( + let current: Element | null = element; + current; + current = current.parentElement + ) { + if ( + current.matches('template,script,style,noscript') || + current.hasAttribute('hidden') || + current.hasAttribute('inert') + ) { + return false; + } + const ariaHidden = current.getAttribute('aria-hidden'); + if ( + ariaHidden !== null && + ariaHidden.trim().toLowerCase() !== 'false' + ) { + return false; + } + const hiddenClass = + current.getAttribute('class')?.toLowerCase() ?? ''; + if ( + /(?:^|\s|-)print-only(?:\s|-|$)|print-portfolio-label|visually-hidden|sr-only|screen-reader-only/u.test( + hiddenClass, + ) + ) { + return false; + } + const style = getComputedStyle(current); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' + ) { + return false; + } + } return ( - htmlElement.closest('[aria-hidden="true"]') === null && - htmlElement.querySelector(leafSelector) === null && - htmlElement.getClientRects().length > 0 && - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' + element.querySelector(leafSelector) === null && + element.getClientRects().length > 0 ); }) .map((element) => - (element as HTMLElement).innerText.replace(/\s+/gu, ' ').trim(), + (element.textContent ?? '').replace(/\s+/gu, ' ').trim(), ) .filter((text) => text.length > 0), visibleTextLeafSelector, ); -const assertVisibleUnobscuredFocus = async ( - page: import('@playwright/test').Page, -): Promise => { - await expect - .poll(() => - page.evaluate(() => { - const active = document.activeElement; - if (!(active instanceof HTMLElement)) return false; - const rect = active.getBoundingClientRect(); - const left = Math.max(0, rect.left); - const right = Math.min(window.innerWidth, rect.right); - const top = Math.max(0, rect.top); - const bottom = Math.min(window.innerHeight, rect.bottom); - if (right <= left || bottom <= top) return false; - const topElement = document.elementFromPoint( - left + (right - left) / 2, - top + Math.min(bottom - top, window.innerHeight) / 2, - ); - const style = getComputedStyle(active); - return ( - topElement !== null && - (active === topElement || active.contains(topElement)) && - style.outlineStyle !== 'none' && - Number.parseFloat(style.outlineWidth) >= 2 - ); - }), - ) - .toBe(true); -}; - test('keeps every raw SSR reading-path segment visible without JavaScript', async ({ page, distUrl, @@ -136,14 +319,23 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn const rawResponse = await request.get(`${distUrl}/`); expect(rawResponse.status()).toBe(200); const rawHtml = await rawResponse.text(); - const expectedVisibleText = await rawVisibleTextSegments(page, rawHtml); + const content = await portfolioContentFromSnapshot(page, rawHtml); + const expectedVisibleText = textMultiset( + expectedVisibleTextSegments(content), + ); + const rawVisibleText = textMultiset( + await rawVisibleTextSegments(page, rawHtml), + ); await page.goto(distUrl, { waitUntil: 'load' }); - const actualVisibleText = await browserVisibleTextSegments(page); + const actualVisibleText = textMultiset( + await browserVisibleTextSegments(page), + ); expect(expectedVisibleText.length).toBeGreaterThan(80); + expect(rawVisibleText).toEqual(expectedVisibleText); expect(actualVisibleText).toEqual(expectedVisibleText); - expect(normalizedText(actualVisibleText.join(' '))).not.toContain( + expect(normalizedText(JSON.stringify(actualVisibleText))).not.toContain( 'preview-semantic-v1', ); @@ -206,6 +398,61 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn expect(failedRequests).toEqual([]); }); +test('content oracle detects a removed highlight and an inert reading path', async ({ + page, + distUrl, + request, +}) => { + const rawResponse = await request.get(`${distUrl}/`); + expect(rawResponse.status()).toBe(200); + const rawHtml = await rawResponse.text(); + const content = await portfolioContentFromSnapshot(page, rawHtml); + const expected = textMultiset(expectedVisibleTextSegments(content)); + const firstHighlight = content.experience[0]?.highlights[0]; + expect(firstHighlight).toBeTruthy(); + + const withoutHighlight = await page.evaluate( + ({ html, highlight }) => { + const document = new DOMParser().parseFromString(html, 'text/html'); + const target = [...document.querySelectorAll('li')].find( + (element) => + (element.textContent ?? '').replace(/\s+/gu, ' ').trim() === + highlight, + ); + if (target === undefined) throw new Error('Expected highlight is absent'); + target.remove(); + return `${document.documentElement.outerHTML}`; + }, + { html: rawHtml, highlight: firstHighlight ?? '' }, + ); + const withoutHighlightText = textMultiset( + await rawVisibleTextSegments(page, withoutHighlight), + ); + expect(withoutHighlightText).not.toEqual(expected); + const missingHighlight = missingText(expected, withoutHighlightText); + expect(missingHighlight).toEqual([[normalizedText(firstHighlight ?? ''), 1]]); + expect(missingText(withoutHighlightText, expected)).toEqual([]); + + const inertMain = await page.evaluate((html) => { + const document = new DOMParser().parseFromString(html, 'text/html'); + const main = document.querySelector('main'); + if (main === null) throw new Error('Expected main is absent'); + main.setAttribute('inert', ''); + return `${document.documentElement.outerHTML}`; + }, rawHtml); + const inertMainText = textMultiset( + await rawVisibleTextSegments(page, inertMain), + ); + expect(inertMainText).not.toEqual(expected); + const missingWhenInert = missingText(expected, inertMainText); + expect(missingWhenInert.length).toBeGreaterThan(80); + expect(missingWhenInert).toContainEqual([ + normalizedText(content.identity.valueProposition), + 1, + ]); + expect(missingText(inertMainText, expected)).toEqual([]); +}); + test('activating the skip link focuses visible portfolio content without JavaScript', async ({ page, distUrl, @@ -222,7 +469,7 @@ test('activating the skip link focuses visible portfolio content without JavaScr 'tabindex', '-1', ); - await assertVisibleUnobscuredFocus(page); + await expectInsetFocusIndicator(page, '#portfolio-content'); }); test('retains native section navigation when scripts are disabled', async ({ diff --git a/tests/helpers/serve-dist.ts b/tests/helpers/serve-dist.ts index f560f09..4a05d0c 100644 --- a/tests/helpers/serve-dist.ts +++ b/tests/helpers/serve-dist.ts @@ -1,7 +1,7 @@ import { readFile, realpath, stat } from 'node:fs/promises'; import { createServer, type ServerResponse } from 'node:http'; import { extname, isAbsolute, relative, resolve, sep } from 'node:path'; -import { expect, test as base } from '@playwright/test'; +import { expect, test as base, type Page } from '@playwright/test'; const LOOPBACK_HOST = '127.0.0.1'; @@ -82,6 +82,40 @@ export interface DistPreviewServer { close(): Promise; } +export async function expectInsetFocusIndicator( + page: Page, + selector: string, +): Promise { + await expect + .poll(() => + page.locator(selector).evaluate((element) => { + const rect = element.getBoundingClientRect(); + const viewport = window.visualViewport; + const viewportLeft = viewport?.offsetLeft ?? 0; + const viewportTop = viewport?.offsetTop ?? 0; + const viewportRight = + viewportLeft + (viewport?.width ?? window.innerWidth); + const viewportBottom = + viewportTop + (viewport?.height ?? window.innerHeight); + const style = getComputedStyle(element); + return { + focused: document.activeElement === element, + intersectsViewport: + rect.right > viewportLeft && + rect.left < viewportRight && + rect.bottom > viewportTop && + rect.top < viewportBottom, + insetIndicator: /\binset\b/iu.test(style.boxShadow), + }; + }), + ) + .toEqual({ + focused: true, + intersectsViewport: true, + insetIndicator: true, + }); +} + export async function startDistServer( distRoot = resolve(process.cwd(), 'dist'), ): Promise {