Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
cf8b1cc30c docs(a11y): visual accessibility audit of Foundation Web (#492)
Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 20s
Smoke Test / smoke (pull_request) Failing after 17s
Validate Config / YAML Lint (pull_request) Failing after 16s
Validate Config / JSON Validate (pull_request) Successful in 16s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 1m5s
Validate Config / Python Test Suite (pull_request) Has been skipped
Validate Config / Shell Script Lint (pull_request) Failing after 29s
Validate Config / Cron Syntax Check (pull_request) Successful in 10s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 10s
Validate Config / Playbook Schema Validation (pull_request) Successful in 20s
PR Checklist / pr-checklist (pull_request) Successful in 3m33s
Architecture Lint / Lint Repository (pull_request) Failing after 16s
Multimodal audit of forge.alexanderwhitestone.com (Gitea 1.25.4).

6 WCAG 2.1 AA violations found:
- V1: No skip navigation link (2.4.1)
- V2: 25 form inputs without labels (3.3.2) — search + filter radios
- V3: Low-contrast footer text (1.4.3)
- V4: Green link color fails 4.5:1 contrast (1.4.3)
- V5: Missing banner landmark (1.3.1)
- V6: Login heading is h4 not h1 (1.3.1)

4 best-practice recommendations:
- R1: Password visibility toggle
- R2: aria-required on required fields
- R3: aria-label on star/fork count links
- R4: <time> elements for timestamps

Also includes:
- scripts/a11y-check.js: reusable automated a11y audit script
- matrix.timmy.foundation unreachable at audit time

Closes #492
2026-04-13 21:44:52 -04:00
2 changed files with 301 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
# Visual Accessibility Audit — Foundation Web Properties
**Issue:** timmy-config #492
**Date:** 2026-04-13
**Label:** gemma-4-multimodal
**Scope:** forge.alexanderwhitestone.com (Gitea 1.25.4)
## Executive Summary
The Foundation's primary accessible web property is the Gitea forge. The Matrix homeserver (matrix.timmy.foundation) is currently unreachable (DNS/SSL issues). This audit covers the forge across three page types: Homepage, Login, and Explore/Repositories.
**Overall: 6 WCAG 2.1 AA violations found, 4 best-practice recommendations.**
---
## Pages Audited
| Page | URL | Status |
|------|-----|--------|
| Homepage | forge.alexanderwhitestone.com | Live |
| Sign In | forge.alexanderwhitestone.com/user/login | Live |
| Explore Repos | forge.alexanderwhitestone.com/explore/repos | Live |
| Matrix/Element | matrix.timmy.foundation | DOWN (DNS/SSL) |
---
## Findings
### P1 — Violations (WCAG 2.1 AA)
#### V1: No Skip Navigation Link (2.4.1)
- **Pages:** All
- **Severity:** Medium
- **Description:** No "Skip to content" link exists. Keyboard users must tab through the full navigation on every page load.
- **Evidence:** Programmatic check returned `skipNav: false`
- **Fix:** Add `<a href="#main" class="skip-link">Skip to content</a>` visually hidden until focused.
#### V2: 25 Form Inputs Without Labels (1.3.1, 3.3.2)
- **Pages:** Explore/Repositories (filter dropdowns)
- **Severity:** High
- **Description:** The search input and all radio buttons in the Filter/Sort dropdowns lack programmatic label associations.
- **Evidence:** Programmatic check found 25 inputs without `label[for=]`, `aria-label`, or `aria-labelledby`
- **Affected inputs:** `q` (search), `archived` (x2), `fork` (x2), `mirror` (x2), `template` (x2), `private` (x2), `sort` (x12), `clear-filter` (x1)
- **Fix:** Add `aria-label="Search repositories"` to search input. Add `aria-label` to each radio button group and individual options.
#### V3: Low-Contrast Footer Text (1.4.3)
- **Pages:** All
- **Severity:** Medium
- **Description:** Footer text (version, page render time) appears light gray on white, likely failing the 4.5:1 contrast ratio.
- **Evidence:** 30 elements flagged as potential low-contrast suspects.
- **Fix:** Darken footer text to at least `#767676` on white (4.54:1 ratio).
#### V4: Green Link Color Fails Contrast (1.4.3)
- **Pages:** Homepage
- **Severity:** Medium
- **Description:** Inline links use medium-green (~#609926) on white. This shade typically fails 4.5:1 for normal body text.
- **Evidence:** Visual analysis identified green links ("run the binary", "Docker", "contributing") as potentially failing.
- **Fix:** Darken link color to at least `#507020` or add an underline for non-color differentiation (SC 1.4.1).
#### V5: Missing Header/Banner Landmark (1.3.1)
- **Pages:** All
- **Severity:** Low
- **Description:** No `<header>` or `role="banner"` element found. The navigation bar is a `<nav>` but not wrapped in a banner landmark.
- **Evidence:** `landmarks.banner: 0`
- **Fix:** Wrap the top navigation in `<header>` or add `role="banner"`.
#### V6: Heading Hierarchy Issue (1.3.1)
- **Pages:** Login
- **Severity:** Low
- **Description:** The Sign In heading is `<h4>` rather than `<h1>`, breaking the heading hierarchy. The page has no `<h1>`.
- **Evidence:** Accessibility tree shows `heading "Sign In" [level=4]`
- **Fix:** Use `<h1>` for "Sign In" on the login page.
---
### P2 — Best Practice Recommendations
#### R1: Add Password Visibility Toggle
- **Page:** Login
- **Description:** No show/hide toggle on the password field. This helps users with cognitive or motor impairments verify input.
#### R2: Add `aria-required` to Required Fields
- **Page:** Login
- **Evidence:** `inputsWithAriaRequired: 0` (no inputs marked as required)
- **Description:** The username field shows a red asterisk but has no `required` or `aria-required="true"` attribute.
#### R3: Improve Star/Fork Link Labels
- **Page:** Explore Repos
- **Description:** Star and fork counts are bare numbers (e.g., "0", "2"). Screen readers announce these without context.
- **Fix:** Add `aria-label="2 stars"` / `aria-label="0 forks"` to count links.
#### R4: Use `<time>` Elements for Timestamps
- **Page:** Explore Repos
- **Description:** Relative timestamps ("2 minutes ago") are human-readable but lack machine-readable fallbacks.
- **Fix:** Wrap in `<time datetime="2026-04-13T17:00:00Z">2 minutes ago</time>`.
---
## What's Working Well
- **Color contrast (primary):** Black text on white backgrounds — excellent 21:1 ratio.
- **Heading structure (homepage):** Clean h1 > h2 > h3 hierarchy.
- **Landmark regions:** `<main>` and `<nav>` landmarks present.
- **Language attribute:** `lang="en-US"` set on `<html>`.
- **Link text:** Descriptive — no "click here" or "read more" patterns found.
- **Form layout:** Login form uses clean single-column with good spacing.
- **Submit button:** Full-width, good contrast, large touch target.
- **Navigation:** Simple, consistent across pages.
---
## Out of Scope
- **matrix.timmy.foundation:** Unreachable (DNS resolution failure / SSL cert mismatch). Should be re-audited when operational.
- **Evennia web client (localhost:4001):** Local-only, not publicly accessible.
- **WCAG AAA criteria:** This audit covers AA only.
---
## Remediation Priority
| Priority | Issue | Effort |
|----------|-------|--------|
| P1 | V2: 25 unlabeled inputs | Medium |
| P1 | V1: Skip nav link | Small |
| P1 | V4: Green link contrast | Small |
| P1 | V3: Footer text contrast | Small |
| P2 | V6: Heading hierarchy | Small |
| P2 | V5: Banner landmark | Small |
| P2 | R1-R4: Best practices | Small |
---
## Automated Check Results
```
skipNav: false
headings: h1(3), h4(1)
imgsNoAlt: 0 / 1
inputsNoLabel: 25
genericLinks: 0
lowContrastSuspects: 30
inputsWithAriaRequired: 0
landmarks: main=1, nav=2, banner=0, contentinfo=2
hasLang: true (en-US)
```
---
*Generated via visual + programmatic analysis of forge.alexanderwhitestone.com*

151
scripts/a11y-check.js Normal file
View File

@@ -0,0 +1,151 @@
// a11y-check.js — Automated accessibility audit script for Foundation web properties
// Run in browser console or via Playwright/Puppeteer
//
// Usage: Paste into DevTools console, or include in automated test suite.
// Returns a JSON object with pass/fail for WCAG 2.1 AA checks.
(function a11yAudit() {
const results = {
timestamp: new Date().toISOString(),
url: window.location.href,
title: document.title,
violations: [],
passes: [],
warnings: []
};
// --- 2.4.1 Skip Navigation ---
const skipLink = document.querySelector('a[href="#main"], a[href="#content"], .skip-nav, .skip-link');
if (skipLink) {
results.passes.push({ rule: '2.4.1', name: 'Skip Navigation', detail: 'Skip link found' });
} else {
results.violations.push({ rule: '2.4.1', name: 'Skip Navigation', severity: 'medium', detail: 'No skip-to-content link found' });
}
// --- 1.3.1 / 3.3.2 Form Labels ---
const unlabeledInputs = Array.from(document.querySelectorAll('input, select, textarea')).filter(el => {
if (el.type === 'hidden') return false;
const id = el.id;
const hasLabel = id && document.querySelector(`label[for="${id}"]`);
const hasAriaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
const hasTitle = el.getAttribute('title');
const hasPlaceholder = el.getAttribute('placeholder'); // placeholder alone is NOT sufficient
return !hasLabel && !hasAriaLabel && !hasTitle;
});
if (unlabeledInputs.length === 0) {
results.passes.push({ rule: '3.3.2', name: 'Form Labels', detail: 'All inputs have labels' });
} else {
results.violations.push({
rule: '3.3.2',
name: 'Form Labels',
severity: 'high',
detail: `${unlabeledInputs.length} inputs without programmatic labels`,
elements: unlabeledInputs.map(el => ({ tag: el.tagName, type: el.type, name: el.name, id: el.id }))
});
}
// --- 1.4.3 Contrast (heuristic: very light text colors) ---
const lowContrast = Array.from(document.querySelectorAll('p, span, a, li, td, th, label, small, footer *')).filter(el => {
const style = getComputedStyle(el);
const color = style.color;
// Check for very light RGB values (r/g/b < 120)
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (!match) return false;
const [, r, g, b] = match.map(Number);
return r < 120 && g < 120 && b < 120 && (r + g + b) < 200;
});
if (lowContrast.length === 0) {
results.passes.push({ rule: '1.4.3', name: 'Contrast', detail: 'No obviously low-contrast text found' });
} else {
results.warnings.push({ rule: '1.4.3', name: 'Contrast', detail: `${lowContrast.length} elements with potentially low contrast (manual verification needed)` });
}
// --- 1.3.1 Heading Hierarchy ---
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).map(h => ({
level: parseInt(h.tagName[1]),
text: h.textContent.trim().substring(0, 80)
}));
let headingIssues = [];
let lastLevel = 0;
for (const h of headings) {
if (h.level > lastLevel + 1 && lastLevel > 0) {
headingIssues.push(`Skipped h${lastLevel} to h${h.level}: "${h.text}"`);
}
lastLevel = h.level;
}
if (headingIssues.length === 0 && headings.length > 0) {
results.passes.push({ rule: '1.3.1', name: 'Heading Hierarchy', detail: `${headings.length} headings, proper nesting` });
} else if (headingIssues.length > 0) {
results.violations.push({ rule: '1.3.1', name: 'Heading Hierarchy', severity: 'low', detail: headingIssues.join('; ') });
}
// --- 1.3.1 Landmarks ---
const landmarks = {
main: document.querySelectorAll('main, [role="main"]').length,
nav: document.querySelectorAll('nav, [role="navigation"]').length,
banner: document.querySelectorAll('header, [role="banner"]').length,
contentinfo: document.querySelectorAll('footer, [role="contentinfo"]').length
};
if (landmarks.main > 0) {
results.passes.push({ rule: '1.3.1', name: 'Main Landmark', detail: 'Found' });
} else {
results.violations.push({ rule: '1.3.1', name: 'Main Landmark', severity: 'medium', detail: 'No <main> or role="main" found' });
}
if (landmarks.banner === 0) {
results.violations.push({ rule: '1.3.1', name: 'Banner Landmark', severity: 'low', detail: 'No <header> or role="banner" found' });
}
// --- 3.3.1 Required Fields ---
const requiredInputs = document.querySelectorAll('input[required], input[aria-required="true"]');
if (requiredInputs.length > 0) {
results.passes.push({ rule: '3.3.1', name: 'Required Fields', detail: `${requiredInputs.length} inputs marked as required` });
} else {
const visualRequired = document.querySelector('.required, [class*="required"], label .text-danger');
if (visualRequired) {
results.warnings.push({ rule: '3.3.1', name: 'Required Fields', detail: 'Visual indicators found but no aria-required attributes' });
}
}
// --- 2.4.2 Page Title ---
if (document.title && document.title.trim().length > 0) {
results.passes.push({ rule: '2.4.2', name: 'Page Title', detail: document.title });
} else {
results.violations.push({ rule: '2.4.2', name: 'Page Title', severity: 'medium', detail: 'Page has no title' });
}
// --- 3.1.1 Language ---
const lang = document.documentElement.lang;
if (lang) {
results.passes.push({ rule: '3.1.1', name: 'Language', detail: lang });
} else {
results.violations.push({ rule: '3.1.1', name: 'Language', severity: 'medium', detail: 'No lang attribute on <html>' });
}
// --- Images without alt ---
const imgsNoAlt = Array.from(document.querySelectorAll('img:not([alt])'));
if (imgsNoAlt.length === 0) {
results.passes.push({ rule: '1.1.1', name: 'Image Alt Text', detail: 'All images have alt attributes' });
} else {
results.violations.push({ rule: '1.1.1', name: 'Image Alt Text', severity: 'high', detail: `${imgsNoAlt.length} images without alt attributes` });
}
// --- Buttons without accessible names ---
const emptyButtons = Array.from(document.querySelectorAll('button')).filter(b => {
return !b.textContent.trim() && !b.getAttribute('aria-label') && !b.getAttribute('aria-labelledby') && !b.getAttribute('title');
});
if (emptyButtons.length === 0) {
results.passes.push({ rule: '4.1.2', name: 'Button Names', detail: 'All buttons have accessible names' });
} else {
results.violations.push({ rule: '4.1.2', name: 'Button Names', severity: 'medium', detail: `${emptyButtons.length} buttons without accessible names` });
}
// Summary
results.summary = {
violations: results.violations.length,
passes: results.passes.length,
warnings: results.warnings.length
};
console.log(JSON.stringify(results, null, 2));
return results;
})();