Skip to content
Draft
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
80 changes: 80 additions & 0 deletions .github/workflows/a11y.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Accessibility Tests

on:
workflow_dispatch:
inputs:
url:
description: 'URL to test (e.g., https://uss-staging.netlify.app). Leave empty to build and test locally.'
required: false
type: string

jobs:
a11y:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Playwright chromium
run: npx playwright install --with-deps chromium

# When testing a deployed URL, just fetch config for test scaffolding
- name: Fetch config
if: ${{ inputs.url != '' }}
env:
CONFIG_URL: ${{ secrets.CONFIG_URL }}
run: curl -fsSL "$CONFIG_URL" -o public/config.json

# When no URL provided, do a full static build
- name: Build static site
if: ${{ inputs.url == '' }}
env:
STATIC_BUILD: 'true'
CONFIG_URL: ${{ secrets.CONFIG_URL }}
GITHUB_OWNER: ${{ secrets.CONTENT_GITHUB_OWNER }}
GITHUB_REPO: ${{ secrets.CONTENT_GITHUB_REPO }}
GITHUB_BRANCH: ${{ secrets.CONTENT_GITHUB_BRANCH }}
GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.CONTENT_GITHUB_PERSONAL_ACCESS_TOKEN }}
MONGODB_URI: ${{ secrets.MONGODB_URI }}
MONGODB_NAME: ${{ secrets.MONGODB_NAME }}
MONGODB_COLLECTION_NAME: ${{ secrets.MONGODB_COLLECTION_NAME }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
run: npm run build

- name: Start preview server
if: ${{ inputs.url == '' }}
run: npx astro preview &

- name: Wait for server
if: ${{ inputs.url == '' }}
run: |
for i in $(seq 1 30); do
curl -sf http://localhost:4321 > /dev/null 2>&1 && exit 0
sleep 1
done
echo "Server failed to start" && exit 1

- name: Run accessibility tests
env:
A11Y_HOST: ${{ inputs.url || 'http://localhost:4321' }}
STATIC_BUILD: ${{ inputs.url != '' && 'false' || 'true' }}
run: npx playwright test --project=chromium

- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-report
path: |
playwright-report/
test-results/
retention-days: 30
92 changes: 92 additions & 0 deletions docs/a11y-requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Accessibility Requirements

Requirements for WCAG 2.2 AA compliance in Core Data Places deployments. Originates from the USS project's UVA Non-Visual Access procurement agreement and applies to all CDP sites.

## Web Content (Automated — Playwright + axe-core)

These are tested automatically by the existing test suite (`test/browser/a11y.test.ts`). See `a11y-test-results.md` for current status.

- WCAG 2.0/2.1/2.2 Level A and AA criteria across all page types
- Image alt text on all meaningful images; decorative images marked appropriately
- Color contrast ratios meeting AA thresholds
- Keyboard navigability for all interactive elements
- Semantic HTML structure (headings, landmarks, lists, tables)
- Form labels, error messages, and focus management
- ARIA attributes where semantic HTML is insufficient

## Web Content (Manual)

These require human review and cannot be fully automated. See `accessibility-checklist.md` in the USS repo for the full manual checklist.

- Screen reader testing (VoiceOver, NVDA, JAWS, TalkBack)
- Logical reading order and content flow
- Meaningful link text and navigation patterns
- Content comprehensibility and reading level
- Assistive technology compatibility across browsers
- Browser zoom behavior (up to 200%)

## PDF Accessibility

PDFs are uploaded by clients into FairData, stored on FairImage, and served as IIIF images. PDF accessibility is the client's responsibility, but we should test uploaded PDFs and report issues so clients can remediate.

**Not testable via Playwright** — PDFs served as IIIF image tiles lose their internal structure. Testing requires tools that inspect the PDF file directly.

### Automated checks (Playwright + pdf-lib or veraPDF)

Pages with PDF download links can be tested in the existing Playwright suite using a hybrid approach:

1. Playwright navigates to a page with a PDF download link
2. Triggers the download via Playwright's download handling API
3. A Node.js PDF library (`pdf-lib`, or shelling out to `veraPDF`) inspects the downloaded file
4. Test asserts on accessibility properties and reports failures

This keeps PDF checks in the same test suite and CI workflow as web content checks. Playwright handles navigation and download; the PDF library handles inspection.

**Properties we can check automatically:**

- **Tagged PDF flag** — confirm the PDF is tagged (structural requirement for screen readers)
- **Document metadata** — title, language, author set in document properties
- **Content copying enabled** — not locked against assistive technology extraction

### Manual checks (client responsibility, we document and report)

These require PAC (PDF Accessibility Checker) or Adobe Acrobat and human judgment:

#### Structure and reading order
- Correct tags for headings, paragraphs, lists, and tables
- Tag tree order matches logical reading order
- Table of Contents tagged appropriately
- Header/footer content tagged where meaningful

#### Images and graphics
- Meaningful images have concise, descriptive alt text
- Decorative images tagged as artifacts
- Text-in-images avoided; scanned content has OCR applied
- Color contrast sufficient; information not conveyed by color alone

#### Lists and tables
- Lists use proper tag hierarchy (L, LI, Lbl, LBody)
- Tables use TH/TD correctly with scope attributes
- Tables have a description/summary
- No empty cells used for layout

#### Links and footnotes
- Link text is descriptive (not "click here")
- Links are keyboard accessible and visually distinct
- New-window links warn users
- Citations and footnotes tagged correctly

#### Forms (if applicable)
- All fields labeled clearly with tooltips and required indicators
- Logical tab order
- Accessible error messages

#### Navigation
- Bookmarks present for documents over 10 pages

### Testing tools

- **PAC (PDF Accessibility Checker)** — free, Windows-only, comprehensive automated checks
- **Adobe Acrobat Pro** — built-in accessibility checker and remediation tools
- **veraPDF** — open source, CLI, good for batch automated checks
- **Screen reader validation** — manual testing with NVDA or VoiceOver reading the PDF directly
57 changes: 57 additions & 0 deletions docs/a11y-test-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Accessibility Test Results

**Date:** 2026-02-19
**Engine:** axe-core 4.11 via `@axe-core/playwright`
**Ruleset:** WCAG 2.0/2.1/2.2 Level A and AA only
**Browser:** Chromium
**Target:** USS content deployment (localhost:4321)

## Summary

| Status | Count |
|--------|-------|
| Passed | 6 |
| Failed | 1 |
| Skipped | 7 |

**1 unique WCAG violation** found. The test suite filters to WCAG 2.2 AA criteria and uses soft assertions to report all violations per page without stopping early.

## WCAG Violations

### Critical: `image-alt` — Images must have alternative text

- **WCAG:** 1.1.1 Non-text Content (Level A)
- **Issue:** [#553](https://github.com/performant-software/core-data-places/issues/553)
- **Pages:** `/en/pages/Institutions`, `/en/pages/About`, `/en/` (Home, intermittent due to `server:defer` timing)
- **Element:** `<img>` in Banner component (`src/apps/pages/Banner.astro` line 52)
- **Root cause:** `Banner.astro` uses `alt={imageAlt}`, but when TinaCMS content omits the `imageAlt` field, the `alt` attribute is undefined (missing entirely).
- **Fix:** Default to `alt={imageAlt || ''}` for decorative images, or require alt text in the CMS schema.

## Best Practice Issues (Not WCAG Criteria)

These were found during initial testing with all axe-core rules enabled. They are not WCAG violations but are worth addressing:

- **`page-has-heading-one`** — 6 pages lack a visible `<h1>` at scan time because the heading is inside `Header.astro` with `server:defer`
- **`region`** — Header and footer use `<div>` instead of semantic landmarks (`<header>`, `<nav>`, `<footer>`)

## Skipped Tests

These were skipped because the USS config doesn't include the relevant features:

- Paths page / Path detail (no paths collection)
- Search table view / filters panel / detail panel (search type is `list`, not `map`)
- Gallery / Gallery item (no gallery URL configured)

## Test Changes Made

1. **Bug fix:** `test/browser/a11y.test.ts:59` — changed `posts/` to `paths/` for path detail page URLs
2. **WCAG filter:** `checkPage` now uses `.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'])` to scan only WCAG criteria
3. **Soft assertions:** `expect.soft()` records all violations without stopping, so every page gets fully scanned

## CI Workflow

`.github/workflows/a11y.yml` — manual trigger (`workflow_dispatch`), chromium only, HTML report uploaded as artifact.

Two modes:
- **With URL input:** tests any deployed URL (staging, production, PR preview)
- **Without URL:** builds static site locally and serves with `astro preview`
1 change: 1 addition & 0 deletions src/apps/session/SearchList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const SearchList = () => {
{ t('savedSearches') }
</h1>
<Button
aria-label={t('clearAll')}
className='bg-red-700 text-white font-bold'
onClick={onDeleteAll}
rounded
Expand Down
2 changes: 1 addition & 1 deletion src/components/ColumnContent.astro
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const BlockTypes = {

---
{ item.__typename.endsWith(BlockTypes.image) && (
<img src={item.image} class={clsx(
<img src={item.image} alt={item.image_alt || ''} class={clsx(
'w-full object-cover',
{ 'h-full': item.full_height },
{ 'rounded-md': item.rounded }
Expand Down
6 changes: 3 additions & 3 deletions src/components/NavDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ const NavDropdown = (props: Props) => {
<DropdownMenu.Trigger
asChild
>
<div
className='flex items-center gap-x-2 cursor-pointer outline-hidden hover:bg-transparent'
<button
className='flex items-center gap-x-2 cursor-pointer outline-hidden hover:bg-transparent border-none bg-transparent p-0'
>
<NavItem
active={props.active}
Expand All @@ -49,7 +49,7 @@ const NavDropdown = (props: Props) => {
<Icon
name='down'
/>
</div>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
Expand Down
14 changes: 9 additions & 5 deletions test/browser/a11y.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ test.describe('Accessibility testing', () => {
const paths = await fetchPaths();

for (const path of paths) {
await page.goto(`posts/${path._sys.filename}`);
await page.goto(`paths/${path._sys.filename}`);
await checkPage(page);
}
});
Expand Down Expand Up @@ -142,6 +142,7 @@ test.describe('Accessibility testing', () => {

test('Sessions should have no violations', async ({ page }) => {
await page.goto('sessions/search');
await page.getByRole('button', { name: /clear/i }).waitFor();
await checkPage(page);
});
});
Expand Down Expand Up @@ -170,9 +171,9 @@ test.describe('Accessibility testing', () => {

// Detail pages
test.describe('Detail pages', () => {
test.skip(!_config.detail_pages, 'No detail pages configured.');
test.skip(!_config.detail_pages?.models, 'No detail pages configured.');

const models = Object.keys(_config.detail_pages.models);
const models = Object.keys(_config.detail_pages?.models || {});

models.forEach((name) => {
test(`${name} should have no violations`, async ({ page }) => {
Expand All @@ -198,6 +199,9 @@ test.describe('Accessibility testing', () => {
* @param message
*/
const checkPage = async (page: Page, message: string = '') => {
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations, message || page.url()).toEqual([]);
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'])
.analyze();

expect.soft(accessibilityScanResults.violations, message || page.url()).toEqual([]);
};
4 changes: 4 additions & 0 deletions tina/content/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,10 @@ const staticSectionTemplates: Template<false>[] = [{
name: 'image',
label: 'Image',
type: 'image'
}, {
name: 'image_alt',
label: 'Image Alt Text',
type: 'string'
}, {
name: 'full_height',
label: 'Full height?',
Expand Down