Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@
{"id":"int-6dca1ed8","kind":"field_change","created_at":"2026-05-19T00:25:02.245463989Z","actor":"Stackwright Bot","issue_id":"stackwright-u4y","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in PR #448 (fix/a11y-cluster): added ArrowLeft/ArrowRight/Home/End keyboard handler to TabbedContentGrid tablist."}}
{"id":"int-818ccb2f","kind":"field_change","created_at":"2026-05-19T00:25:02.779836381Z","actor":"Stackwright Bot","issue_id":"stackwright-8je","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in PR #448 (fix/a11y-cluster): darkColors.primary changed to #92400E and accent to #B45309 in stackwright-docs."}}
{"id":"int-e45f120a","kind":"field_change","created_at":"2026-05-19T00:25:03.296134424Z","actor":"Stackwright Bot","issue_id":"stackwright-5y5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in PR #448 (fix/a11y-cluster): OverflowImageCard now uses getBetterTextColor('#1a1a1a','#FFFFFF',backgroundColor)."}}
{"id":"int-e0915e56","kind":"field_change","created_at":"2026-05-20T16:24:49.474530874Z","actor":"Stackwright Bot","issue_id":"stackwright-lww","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-0d30edd0","kind":"field_change","created_at":"2026-05-20T16:32:10.421710691Z","actor":"Stackwright Bot","issue_id":"stackwright-wao","extra":{"field":"assignee","new_value":"planning-agent-2840e9","old_value":""}}
{"id":"int-b9c85641","kind":"field_change","created_at":"2026-05-20T16:41:06.035970216Z","actor":"Stackwright Bot","issue_id":"stackwright-wao","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
2 changes: 1 addition & 1 deletion .beads/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{"_type":"issue","id":"stackwright-8je","title":"a11y: dark mode #FCC03E amber text on near-white backgrounds fails WCAG AA","description":"Dark theme uses #FCC03E (amber/yellow) for headings and sidebar links but darkColors resolves to near-white backgrounds (#fdfdfd, #f5f5f5, #f6f6f6), producing contrast ratios of 1.51–1.61. WCAG AA requires 4.5:1. Fix: darken darkColors backgrounds to ~#1a1a1a/#2a2a2a. Affects @stackwright/themes dark color configuration. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/439","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:34:07Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:57Z","closed_at":"2026-05-19T00:21:57Z","close_reason":"Fixed: changed darkColors.primary to #92400E and accent to #B45309 in stackwright-docs stackwright.yml in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-6hl","title":"a11y: code_block tabindex=0 on \u003cpre\u003e creates keyboard trap (WCAG 2.1.2)","description":"code_block component adds tabindex=0 to \u003cpre\u003e elements making them focusable, but Tab cannot escape once focus enters. WCAG 2.1 SC 2.1.2 No Keyboard Trap violation. Fix: remove tabindex or add keyboard escape mechanism. Affects Getting Started, CLI Reference, and Otter Raft pages. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/440","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:34:02Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:55Z","closed_at":"2026-05-19T00:21:55Z","close_reason":"Fixed: removed tabIndex={0} from CodeBlock \u003cpre\u003e in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-mca","title":"a11y: skip link present but does not move focus to main content","description":"Skip link is in DOM but clicking/activating it does not move focus to main content area. Fix: add id='main-content' to \u003cmain\u003e wrapper in page layout and ensure skip link href points to #main-content. Affects @stackwright/core page layout. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/441","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:57Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:22:33Z","closed_at":"2026-05-19T00:22:33Z","close_reason":"Already fixed: PageLayout.tsx line 103 has href='#main-content' on skip link, line 173 has id='main-content' on \u003cmain\u003e. Correct implementation already shipped.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-a45","title":"feat(cli): stackwright test:a11y — portable WCAG accessibility audit for any Stackwright site","description":"Add stackwright test:a11y CLI command. Auto-discovers pages from prebuild output, tests WCAG 2.1 AA via axe-core + Playwright, tests both light/dark modes. Phased implementation: (1) page discovery utility, (2) portable axe runner, (3) CLI command, (4) launch-stackwright integration, (5) MCP/Otter integration. High-value differentiator for enterprise/regulated-environment angle. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/270","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:54Z","created_by":"Stackwright Bot","updated_at":"2026-05-18T22:33:54Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-a45","title":"feat(cli): stackwright test:a11y — portable WCAG accessibility audit for any Stackwright site","description":"Add stackwright test:a11y CLI command. Auto-discovers pages from prebuild output, tests WCAG 2.1 AA via axe-core + Playwright, tests both light/dark modes. Phased implementation: (1) page discovery utility, (2) portable axe runner, (3) CLI command, (4) launch-stackwright integration, (5) MCP/Otter integration. High-value differentiator for enterprise/regulated-environment angle. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/270","status":"closed","priority":2,"issue_type":"feature","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:54Z","created_by":"Stackwright Bot","updated_at":"2026-05-20T17:01:34Z","started_at":"2026-05-20T16:49:40Z","closed_at":"2026-05-20T17:01:34Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-u4y","title":"a11y: tabbed_content component missing ARIA arrow-key keyboard pattern","description":"The tabbed_content component does not respond to ArrowLeft/ArrowRight keys to switch tabs, violating ARIA APG Tabs pattern. Only mouse/click works. Fix: add keydown handler on each tab button to move focus and activate prev/next tab on arrow keys. Affects @stackwright/ui-shadcn or @stackwright/core tabs component. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/442","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:53Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:56Z","closed_at":"2026-05-19T00:21:56Z","close_reason":"Fixed: added ArrowLeft/ArrowRight/Home/End keyboard handler to TabbedContentGrid tablist in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-als","title":"feat(cli): add CLI commands for integration management (list/get/add)","description":"Add stackwright integrations list/get/add CLI commands with interactive prompts (inquirer) and colored output (chalk). Depends on integration schema. Estimated 2-3 hours. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/238","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:51Z","created_by":"Stackwright Bot","updated_at":"2026-05-18T22:33:51Z","dependencies":[{"issue_id":"stackwright-als","depends_on_id":"stackwright-5ak","type":"blocks","created_at":"2026-05-18T18:34:27Z","created_by":"Stackwright Bot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-y6m","title":"a11y: Content Types page missing \u003ctitle\u003e element and lang attribute on \u003chtml\u003e","description":"The /content-types page is missing a non-empty \u003ctitle\u003e element and the lang attribute on \u003chtml\u003e. Both are WCAG 2.1 Level A requirements (SC 2.4.2 Page Titled, SC 3.1.1 Language of Page). Fix: ensure page \u003chead\u003e includes \u003ctitle\u003e and \u003chtml\u003e has lang='en'. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/443","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:49Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:58Z","closed_at":"2026-05-19T00:21:58Z","close_reason":"Already fixed: content-types/content.yml has meta.title set, StackwrightDocument defaults lang='en'. No code change needed.","dependency_count":0,"dependent_count":0,"comment_count":0}
Expand Down
6 changes: 6 additions & 0 deletions .changeset/feat-test-a11y-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@stackwright/cli": minor
"@stackwright/mcp": minor
---

Add `stackwright test:a11y` command for portable WCAG 2.1 AA accessibility auditing. Tests all pages (auto-discovered) in both light and dark modes using axe-core + Playwright. Also exposes `stackwright_test_a11y` MCP tool for Otter agent integration.
4 changes: 4 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@
"zod": "^4.3.6"
},
"peerDependencies": {
"@axe-core/playwright": "^4.11.1",
"playwright": "^1.52.0"
},
"peerDependenciesMeta": {
"@axe-core/playwright": {
"optional": true
},
"playwright": {
"optional": true
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { registerIntegration } from './commands/integration';
import { registerCompose } from './commands/compose';
import { registerPreview } from './commands/preview';
import { registerSBOM } from './commands/sbom';
import { registerTestA11y } from './commands/a11y';

const { version } = require('../package.json') as { version: string };

Expand Down Expand Up @@ -44,6 +45,7 @@ async function main(): Promise<void> {
registerCompose(program);
registerPreview(program);
registerSBOM(program);
registerTestA11y(program);

// Pre-parse to extract global options (including --plugin-dir) before full dispatch.
// parseOptions() does NOT dispatch commands — it only extracts options.
Expand Down
182 changes: 182 additions & 0 deletions packages/cli/src/commands/a11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { detectProject } from '../utils/project-detector';
import { discoverPageSlugs } from '../utils/a11y-page-discovery';
import { runA11yAudit } from '../utils/a11y-runner';
import type { A11yAuditResult, A11yRunnerOptions } from '../utils/a11y-runner';
import { outputResult, outputError, getErrorCode } from '../utils/json-output';

export type { A11yAuditResult };

export interface TestA11yOptions {
baseUrl?: string;
pages?: string; // comma-separated slugs
darkMode?: boolean; // default true (tests both light + dark)
tags?: string; // comma-separated axe rule tags
failOn?: string; // 'minor' | 'moderate' | 'serious' | 'critical'
json?: boolean;
}

/**
* Pure function — run an accessibility audit for a Stackwright project.
* Auto-discovers pages from the project's pages directory if no slugs provided.
*/
export async function testA11y(
projectRoot: string,
opts: TestA11yOptions
): Promise<A11yAuditResult> {
const baseUrl = opts.baseUrl ?? 'http://localhost:3000';

// Resolve slugs: explicit list or auto-discover
let slugs: string[];
if (opts.pages) {
slugs = opts.pages
.split(',')
.map((s) => s.trim())
.filter(Boolean);
} else {
slugs = discoverPageSlugs(projectRoot);
if (slugs.length === 0) {
const err = new Error('No pages found. Run stackwright prebuild first or specify --pages.');
(err as NodeJS.ErrnoException).code = 'NO_PAGES';
throw err;
}
}

const modes: ('light' | 'dark')[] = opts.darkMode === false ? ['light'] : ['light', 'dark'];

const tags = opts.tags
? opts.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
: undefined;

const failOn = (opts.failOn as A11yRunnerOptions['failOn']) ?? 'serious';

return runA11yAudit({ baseUrl, slugs, modes, tags, failOn });
}

// ---------------------------------------------------------------------------
// Human-readable formatter
// ---------------------------------------------------------------------------

function formatAuditResult(result: A11yAuditResult): void {
const { summary } = result;

console.log('');
console.log(chalk.bold('♿ Stackwright Accessibility Audit'));
console.log(chalk.dim(` Base URL: ${result.baseUrl}`));
console.log(chalk.dim(` Modes: ${result.modes.join(', ')}`));
console.log('');

for (const pageResult of result.results) {
const icon = pageResult.pass ? chalk.green('✓') : chalk.red('✗');
const modeLabel = chalk.dim(`[${pageResult.mode}]`);
const label = `${icon} ${chalk.bold(pageResult.slug)} ${modeLabel}`;

if (pageResult.pass) {
console.log(` ${label}`);
} else {
console.log(` ${label}`);
for (const v of pageResult.failingViolations) {
const impact = v.impact ?? 'unknown';
const impactColor =
impact === 'critical' ? chalk.red : impact === 'serious' ? chalk.yellow : chalk.dim;
console.log(
` ${impactColor(`[${impact}]`)} ${v.id}: ${v.help} (${v.nodeCount} node${v.nodeCount !== 1 ? 's' : ''})`
);
console.log(` ${chalk.dim(v.helpUrl)}`);
}
}
}

console.log('');
const summaryLine = ` ${summary.total} scan${summary.total !== 1 ? 's' : ''} — ${chalk.green(`${summary.passed} passed`)}, ${summary.failed > 0 ? chalk.red(`${summary.failed} failed`) : chalk.green('0 failed')}`;
console.log(summaryLine);

if (summary.violations > 0) {
console.log(
chalk.dim(
` ${summary.violations} total violation${summary.violations !== 1 ? 's' : ''} found`
)
);
}

console.log('');

if (result.pass) {
console.log(chalk.green(' ✓ All pages pass WCAG 2.1 AA'));
} else {
console.log(chalk.red(' ✗ Accessibility violations found — see details above'));
}

console.log('');
}

// ---------------------------------------------------------------------------
// Commander registration
// ---------------------------------------------------------------------------

export function registerTestA11y(program: Command): void {
program
.command('test:a11y [slug]')
.description(
'Run a WCAG 2.1 AA accessibility audit against a running Stackwright dev server.\n' +
'Auto-discovers pages from the project. Requires: pnpm dev (running) + playwright installed.'
)
.option('--base-url <url>', 'Dev server URL (default: http://localhost:3000)')
.option(
'--pages <slugs>',
'Comma-separated page slugs to test (default: auto-discover all pages)'
)
.option('--no-dark-mode', 'Skip dark mode testing (test light mode only)')
.option(
'--tags <tags>',
'Comma-separated axe rule tags (default: wcag2a,wcag2aa,wcag21a,wcag21aa)'
)
.option(
'--fail-on <level>',
'Minimum violation impact level that fails the audit: minor|moderate|serious|critical (default: serious)',
'serious'
)
.option('--json', 'Output machine-readable JSON')
.action(async (slug: string | undefined, opts: TestA11yOptions & { darkMode?: boolean }) => {
// If a positional slug was given, treat it as --pages
if (slug) {
opts.pages = slug;
}

const json = Boolean(opts.json);

try {
const project = detectProject();
const result = await testA11y(project.root, opts);

if (result.pass) {
outputResult(result, { json }, () => formatAuditResult(result));
} else {
// Violations found — output the result then exit 1
if (json) {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
formatAuditResult(result);
}
process.exit(1);
}
} catch (err) {
const code = getErrorCode(err);
if (
code === 'NO_DEV_SERVER' ||
code === 'MISSING_PLAYWRIGHT' ||
code === 'MISSING_AXE' ||
code === 'NO_PAGES' ||
code === 'NOT_A_PROJECT'
) {
outputError(String((err as Error).message), code, { json });
} else {
outputError(String((err as Error).message), 'A11Y_AUDIT_FAILED', { json }, 2);
}
}
});
}
8 changes: 8 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { listCollections, addCollection, resolveContentDir } from './commands/co
export { listIntegrations, getIntegration, addIntegration } from './commands/integration';
export { composeSite } from './commands/compose';
export { preview } from './commands/preview';
export { testA11y } from './commands/a11y';
export { validateSiteComposition } from './utils/site-validator';
export { detectProject, resolvePagesDir } from './utils/project-detector';

Expand Down Expand Up @@ -64,6 +65,13 @@ export type {
} from './commands/integration';
export type { ComposeSiteResult, ComposeSiteOptions } from './commands/compose';
export type { PreviewResult, PreviewOptions } from './commands/preview';
export type { TestA11yOptions, A11yAuditResult } from './commands/a11y';
export type {
A11yViolation,
A11yPageResult,
A11yRunnerOptions,
A11yColorMode,
} from './utils/a11y-runner';
export type {
ValidateSiteCompositionResult,
ValidateSiteCompositionOptions,
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/utils/a11y-page-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { listPages } from '../commands/page';
import { resolvePagesDir } from './project-detector';

/**
* Discover all page URL paths for a Stackwright project.
*
* Reads the pages directory (content.yml files) and returns each page as a
* URL-style slug (e.g. '/', '/about', '/docs/getting-started').
*
* Used by `stackwright test:a11y` to auto-discover pages when no explicit
* slug list is provided.
*/
export function discoverPageSlugs(projectRoot: string): string[] {
const pagesDir = resolvePagesDir(projectRoot);
const result = listPages(pagesDir);
return result.pages.map((p) => p.slug);
}
Loading
Loading