From 2f7ba16a6065f90033bd8a6d177643c38bc56575 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Thu, 17 Sep 2026 20:41:44 -0500 Subject: [PATCH 1/5] Implemented WCAG 2.2 AA compliance for accessibility features --- AGENTS.md | 16 +- README.md | 10 + app/about/page.jsx | 2 + app/api/page.jsx | 2 + app/documentation/page.jsx | 2 + app/examples/page.jsx | 2 + app/faq/page.jsx | 2 + app/feedback/page.jsx | 2 + app/getting-started/page.jsx | 2 + app/globals.css | 10 +- app/privacy-policy/page.jsx | 2 + app/terms-of-service/page.jsx | 2 + assets/refactor-first-report.mustache | 243 +++++++++++-------- lib/renderer.js | 81 ++++--- public/assets/refactor-first-report.mustache | 243 +++++++++++-------- tests/e2e/user-journeys.spec.js | 8 +- tests/unit/css-a11y.test.js | 115 +++++++++ tests/unit/html5-attributes.test.js | 107 ++++++++ tests/unit/page-titles.test.js | 36 +++ tests/unit/report-template-wcag.test.js | 108 +++++++++ 20 files changed, 760 insertions(+), 235 deletions(-) create mode 100644 tests/unit/css-a11y.test.js create mode 100644 tests/unit/html5-attributes.test.js create mode 100644 tests/unit/page-titles.test.js create mode 100644 tests/unit/report-template-wcag.test.js diff --git a/AGENTS.md b/AGENTS.md index 630e7b2..e6c39fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,14 @@ tests/ # unit/ (Bun), integration/ (Bun + RTL/jsdom), e2e/ 3. Write the minimal implementation in `lib/` / `components/` / `app/` 4. Refactor while keeping tests green +**Accessibility is mandatory** — every feature MUST comply with WCAG 2.2 AA +and all markup MUST use HTML5-valid elements/attributes (no obsolete +presentational attributes; presentation lives in CSS). New pages, components +and template changes must keep the a11y guards green: +`tests/unit/html5-attributes.test.js`, `tests/unit/report-template-wcag.test.js`, +`tests/unit/css-a11y.test.js`, `tests/unit/page-titles.test.js` — and add +coverage there when introducing new markup patterns. + ## Platform-Aware Sections in This Repo - **CSP** lives in `app/layout.jsx` (`script-src` includes the CDN widget hosts and @@ -111,4 +119,10 @@ tests/ # unit/ (Bun), integration/ (Bun + RTL/jsdom), e2e/ ## Current Test Count -~294 unit/integration + 112 E2E (three browsers + basePath leg). +~313 unit/integration + 112 E2E (three browsers + basePath leg). + +WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js, +tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and +tests/unit/page-titles.test.js — the report mustache keeps a single h1, +scoped table headers, captions, labelled canvases and a named nav; obsolete +presentational attributes are FORBID_ATTR-stripped in lib/renderer.js. diff --git a/README.md b/README.md index 7ff6609..1889b7c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ is a **Next.js static export** (`bun run build` produces `out/`, which any stati platform account is captured as the issue author and validated server-side by the platform's CI - Reports and submissions work for repositories hosted on the same platform as the deployment (GitHub, GitLab or Bitbucket) +- **Accessible**: the site and the rendered reports comply with **WCAG 2.2 AA** (semantic HTML5, visible focus + indicators, AA color contrast, per-page titles, table captions and scoped headers, labeled controls and landmarks) - Works with a plain static file server: `python3 -m http.server 8000` --- @@ -370,6 +372,14 @@ npx playwright test --ui # interactive mode bun run test:e2e:basepath # NEXT_PUBLIC_BASE_PATH=/preview leg (chromium) ``` +### Accessibility (WCAG 2.2 AA) + +This application complies with **WCAG 2.2 Level AA**, and every change must keep it that way: the dedicated guards in +`tests/unit/html5-attributes.test.js`, `tests/unit/report-template-wcag.test.js`, `tests/unit/css-a11y.test.js` and +`tests/unit/page-titles.test.js` verify HTML5-valid markup (no obsolete presentational attributes), semantic heading +hierarchy, table captions and scoped headers, chart alternative text, landmark names, color contrast, focus visibility, +target sizes and per-page titles. Extend these tests when you introduce new markup patterns. + ### Lint ```bash diff --git a/app/about/page.jsx b/app/about/page.jsx index 0d85e57..7dddce7 100644 --- a/app/about/page.jsx +++ b/app/about/page.jsx @@ -1,3 +1,5 @@ +export const metadata = { title: 'About - RefactorFirst' }; + export default function AboutPage() { return (
diff --git a/app/api/page.jsx b/app/api/page.jsx index a570de2..9c0b3d9 100644 --- a/app/api/page.jsx +++ b/app/api/page.jsx @@ -1,3 +1,5 @@ +export const metadata = { title: 'API - RefactorFirst' }; + export default function ApiPage() { return (
diff --git a/app/documentation/page.jsx b/app/documentation/page.jsx index 4290304..514eede 100644 --- a/app/documentation/page.jsx +++ b/app/documentation/page.jsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +export const metadata = { title: 'Documentation - RefactorFirst' }; + export default function DocumentationPage() { return (
diff --git a/app/examples/page.jsx b/app/examples/page.jsx index 5b621d4..17c74ae 100644 --- a/app/examples/page.jsx +++ b/app/examples/page.jsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +export const metadata = { title: 'Example Reports - RefactorFirst' }; + export default function ExamplesPage() { return (
diff --git a/app/faq/page.jsx b/app/faq/page.jsx index 69b1a44..62dccf8 100644 --- a/app/faq/page.jsx +++ b/app/faq/page.jsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +export const metadata = { title: 'FAQ - RefactorFirst' }; + export default function FaqPage() { return (
diff --git a/app/feedback/page.jsx b/app/feedback/page.jsx index 36fa7fb..88542f5 100644 --- a/app/feedback/page.jsx +++ b/app/feedback/page.jsx @@ -1,3 +1,5 @@ +export const metadata = { title: 'Feedback - RefactorFirst' }; + export default function FeedbackPage() { return (
diff --git a/app/getting-started/page.jsx b/app/getting-started/page.jsx index a91a077..5290d09 100644 --- a/app/getting-started/page.jsx +++ b/app/getting-started/page.jsx @@ -1,6 +1,8 @@ import Link from 'next/link'; import WorkflowSample from '../../components/workflow-sample'; +export const metadata = { title: 'Getting Started - RefactorFirst' }; + export default function GettingStartedPage() { return (
diff --git a/app/globals.css b/app/globals.css index 043829a..8f3b9ab 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,7 +2,10 @@ :root { --brand-color: #2a6f97; - --brand-accent: #61c0bf; + /* Darker accent: white text on it passes WCAG 2.2 AA contrast (1.4.3) and + it serves as the visible focus indicator (2.4.11, >= 3:1 against the + white page background). */ + --brand-accent: #1f5273; --text-color: #1f2d3d; --muted-color: #5c6b7a; --bg-color: #ffffff; @@ -49,7 +52,10 @@ main#app { min-height: 60vh; } -main:focus { +/* Skip-link target: suppress the outline only for programmatic (script/ + click) focus; keyboard-focusable users keep the visible indicator (WCAG + 2.4.7 / 2.4.11). */ +main:focus:not(:focus-visible) { outline: none; } diff --git a/app/privacy-policy/page.jsx b/app/privacy-policy/page.jsx index 7ae0f45..a3bb4de 100644 --- a/app/privacy-policy/page.jsx +++ b/app/privacy-policy/page.jsx @@ -1,3 +1,5 @@ +export const metadata = { title: 'Privacy Policy - RefactorFirst' }; + export default function PrivacyPolicyPage() { return (
diff --git a/app/terms-of-service/page.jsx b/app/terms-of-service/page.jsx index 57ba59a..3d9469f 100644 --- a/app/terms-of-service/page.jsx +++ b/app/terms-of-service/page.jsx @@ -1,3 +1,5 @@ +export const metadata = { title: 'Terms of Service - RefactorFirst' }; + export default function TermsOfServicePage() { return (
diff --git a/assets/refactor-first-report.mustache b/assets/refactor-first-report.mustache index 9aa29bc..06a70e1 100644 --- a/assets/refactor-first-report.mustache +++ b/assets/refactor-first-report.mustache @@ -69,6 +69,33 @@ width: 95%; margin: 20px auto; } + + /* HTML5 replacements for the obsolete presentational attributes the + upstream template used (align/border): text alignment, centered + blocks and the data-table chrome now come from these classes. */ + .rf-text-center { + text-align: center; + } + + .rf-text-left { + text-align: left; + } + + .rf-text-right { + text-align: right; + } + + .rf-data-table { + border: 5px solid; + border-collapse: collapse; + margin: 0 auto; + } + + .rf-data-table th, + .rf-data-table td { + border: 1px solid; + padding: 2px 6px; + }
@@ -76,13 +103,13 @@
-

+

RefactorFirst Report for {{project.name}} {{project.version}}

-
+

Show RefactorFirst some ❤️

Star @@ -99,9 +126,9 @@ data-size="large" aria-label="Sponsor @jimbethancourt on GitHub">Sponsor
-
{{#project.analysisFailed}} -
+ {{/project.analysisFailed}} -

Class Map

+

Class Map

-
+
Red lines represent relationships to remove.
Red nodes represent classes to remove.
Zoom in / out with your mouse wheel and click/move to drag the image.
@@ -171,50 +198,51 @@
{{#classMap.dotThresholdExceeded}} -
SVG is too big to render quickly
+
SVG is too big to render quickly
{{/classMap.dotThresholdExceeded}} {{^classMap.dotThresholdExceeded}}
{{/classMap.dotThresholdExceeded}} -
-
-
-
+
+
+
+
{{#classRelationshipsToRemove.hasRelationships}} - -

Refactor Starting with Priority 1

-
+

Class Relationship Removal Priority

+

Refactor Starting with Priority 1

+
Current Class Cycle Count: {{classRelationshipsToRemove.cycleCount}}
Number of Class Relationships to Remove: {{classRelationshipsToRemove.relationshipsToRemoveCount}}
Classes with * should be broken apart
Removing class relationships below will eliminate class cycles
-
- +
+
+ - - - - - - + + + + + + {{#classRelationshipsToRemove.relationships}} - - - - - + + + + - + {{/classRelationshipsToRemove.relationships}} @@ -222,14 +250,14 @@ {{/classRelationshipsToRemove.hasRelationships}} -
-
-
-
+
+
+
+
{{#packageMap.hasEdges}} -

Package Map

+

Package Map

Class relationships to remove, in priority order
Class RelationshipPriorityIn Class
Cycles
Relationship
Strength
Also Removes Pkg
Cycle Relationship
In Package
Cycles
Class RelationshipPriorityIn Class
Cycles
Relationship
Strength
Also Removes Pkg
Cycle Relationship
In Package
Cycles
{{renderedLabel}}{{priority}}{{cycleCount}}{{effortRank}}{{#alsoRemovesPackageRelationship}} + {{renderedLabel}}{{priority}}{{cycleCount}}{{effortRank}}{{#alsoRemovesPackageRelationship}} true{{/alsoRemovesPackageRelationship}}{{^alsoRemovesPackageRelationship}} false{{/alsoRemovesPackageRelationship}}{{packageCycleCount}}{{packageCycleCount}}
+
+
+ - - - - - + + + + + {{#packageRelationshipsToRemove.relationships}} - - - - - + + + + + {{/packageRelationshipsToRemove.relationships}} @@ -304,41 +333,51 @@ {{/packageRelationshipsToRemove.hasRelationships}} + {{#hasDisharmonies}} +
+
+

Code Disharmonies

+ {{/hasDisharmonies}} {{#disharmonies}} -
-
-
-
- -
-
Package relationships to remove, in priority order
Package RelationshipPriorityIn Pkg
Cycles
Relationship
Strength
Class Relationships to Remove
To Break Package Relationship
Package RelationshipPriorityIn Pkg
Cycles
Relationship
Strength
Class Relationships to Remove
To Break Package Relationship
{{{renderedLabel}}}{{priority}}{{cycleCount}}{{effortRank}}{{#classRelationshipsToBreakPackage}} - {{{.}}}
{{/classRelationshipsToBreakPackage}}
{{{renderedLabel}}}{{priority}}{{cycleCount}}{{effortRank}}{{#classRelationshipsToBreakPackage}} + {{{.}}}
{{/classRelationshipsToBreakPackage}}
+
+
+
+
+

{{title}}

+
+
+ + - + - + +
Problem and recommended solution for {{title}}
Problem:Problem: {{problem}}
Solution:Solution: {{{solution}}}
-
+
- + Bubble chart visualizing the {{title}} findings: one bubble per class, ranked by priority and estimated effort.
-
+
-

{{title}} by the numbers: (Refactor Starting with Priority 1)

-
- +

{{title}} by the numbers: (Refactor Starting with Priority 1)

+
+
+ {{#table.headers}} - + {{/table.headers}} @@ -346,7 +385,7 @@ {{#table.rows}} {{#cells}} - + {{/cells}} {{/table.rows}} @@ -355,32 +394,33 @@ {{/disharmonies}} -
-
-
-
+
+
+
+
{{#classCycles.hasCycles}} - -

Class Cycles by the numbers:

-
-
{{title}} findings, in priority order
{{.}}{{.}}
{{{content}}}{{{content}}}
+

Class Cycles

+

Class Cycles by the numbers:

+
+
+ - - - - + + + + {{#classCycles.summary}} - - - - + + + + {{/classCycles.summary}} @@ -388,8 +428,8 @@ {{#classCycles.largestCycle.hasCycleMap}} -

Largest Class Cycle : {{classCycles.largestCycle.cycleName}}

-

Limiting number of cycles displayed to 1 to keep page load time fast

+

Largest Class Cycle : {{classCycles.largestCycle.cycleName}}

+

Limiting number of cycles displayed to 1 to keep page load time fast

Class cycles summary
Cycle NamePriorityClass CountRelationship CountCycle NamePriorityClass CountRelationship Count
{{cycleName}}{{priority}}{{classCount}}{{relationshipCount}}{{cycleName}}{{priority}}{{classCount}}{{relationshipCount}}
+
+
+ - - + + {{#classCycles.largestCycle.breakdown}} - - + + {{/classCycles.largestCycle.breakdown}} @@ -433,7 +474,7 @@
-
+
Last Published: {{project.scanTimestamp}}
diff --git a/lib/renderer.js b/lib/renderer.js index 3c316ad..2a837a9 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -15,6 +15,22 @@ export function initializeMustache() { return mustacheInstance; } +// Report data embeds raw markup ({{{renderedLabel}}} link lists) that opens +// GitHub in a new tab. Blanket-add rel="noopener noreferrer" so those anchors +// can never reach window.opener, no matter what the report data contains. +// The hook can only register in the browser: server-side DOMPurify without a +// window is a factory with no addHook. +let linkSafetyHookRegistered = false; +function registerLinkSafetyHook() { + if (linkSafetyHookRegistered || typeof DOMPurify.addHook !== 'function') return; + DOMPurify.addHook('afterSanitizeAttributes', node => { + if (node.tagName === 'A' && node.getAttribute('target') === '_blank') { + node.setAttribute('rel', 'noopener noreferrer'); + } + }); + linkSafetyHookRegistered = true; +} + // Report JSON data and Mustache templates come from the target repository // (untrusted third-party content). Escape via Mustache by default and // sanitize the final HTML with DOMPurify so template-supplied '], + ['iframe', ''], + ['object', ''], + ['embed', ''], + ['meta', ''], + ['link', ''], + ['form', ''], + ['input', ''], + ['select', ''], + ['textarea', ''] + ]; + + for (const [tag, markup] of forbiddenElements) { + it(`strips forbidden <${tag}> elements from raw interpolations`, () => { + const html = renderTemplate('
{{{content}}}
', { content: markup }); + const document = new JSDOM(html).window.document; + + expect(document.querySelector(tag)).toBeNull(); + expect(document.querySelector('section')).not.toBeNull(); + }); + } + it('should handle empty data', () => { const template = '{{name}}'; const result = renderTemplate(template, {}); @@ -128,6 +151,19 @@ describe('templating safety (repository-provided templates are untrusted)', () = expect(document.querySelector('div[target="_blank"]')?.hasAttribute('rel')).toBe(false); }); + it('removes an explicit opener relationship from links that open a new context', () => { + const html = renderTemplate( + 'New tab', + {} + ); + const link = new JSDOM(html).window.document.querySelector('a'); + const relationships = link.getAttribute('rel').split(/\s+/); + + expect(relationships).toContain('noopener'); + expect(relationships).toContain('noreferrer'); + expect(relationships).not.toContain('opener'); + }); + it('keeps benign structure, style attributes and data attributes intact', () => { const html = renderTemplate( '', diff --git a/tests/unit/report-template-wcag.test.js b/tests/unit/report-template-wcag.test.js index b86917c..f4bb753 100644 --- a/tests/unit/report-template-wcag.test.js +++ b/tests/unit/report-template-wcag.test.js @@ -115,4 +115,31 @@ describe('report template WCAG 2.2 AA (rendered with fixture data)', () => { expect(docWithoutCycleMap.querySelector('a[href="#CYCLEMAP"]')).toBeNull(); }); + + it('omits optional navigation links when their report sections are absent', () => { + const sparseFixture = structuredClone(fixture); + sparseFixture.classRelationshipsToRemove.hasRelationships = false; + sparseFixture.packageMap.hasEdges = false; + sparseFixture.packageRelationshipsToRemove.hasRelationships = false; + sparseFixture.hasDisharmonies = false; + sparseFixture.disharmonies = []; + sparseFixture.classCycles.hasCycles = false; + + const sparseDoc = new JSDOM(renderTemplate(template, sparseFixture)).window.document; + const optionalTargets = [ + '#CLASSEDGES', + '#PACKAGEMAP', + '#PACKAGEEDGES', + '#DISHARMONIES', + '#CYCLES', + '#CYCLEMAP' + ]; + + expect(sparseDoc.querySelector('a[href="#CLASSMAP"]')).not.toBeNull(); + expect(sparseDoc.getElementById('CLASSMAP')).not.toBeNull(); + for (const target of optionalTargets) { + expect(sparseDoc.querySelector(`a[href="${target}"]`)).toBeNull(); + expect(sparseDoc.getElementById(target.slice(1))).toBeNull(); + } + }); });
Classes and relationships in the largest cycle
ClassesRelationshipsClassesRelationships
{{{className}}}{{{edgesHtml}}}{{{className}}}{{{edgesHtml}}}