From 33937ea9b81ed02a6f71349a39dd9c5a52a1bebf Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:23:04 -0500 Subject: [PATCH 1/3] fix(website): remediate WCAG accessibility issues and add axe regression guardrail Fixes accessibility violations found by an axe-core sweep of every website page in both light and dark themes: - aria-allowed-role (WCAG 1.3.1, 4.1.2): the shared resource card rendered an
with role="listitem", which is not an allowed role for that element. Switched the wrapper to a
so the listitem role is valid (affected every card, e.g. #arcade-canvas on /extensions/). - aria-required-children (WCAG 1.3.1): removed role="list" from the tools, contributors, and cookbook containers whose children are not list items. - nested-interactive (WCAG 4.1.2): removed tabIndex=0 from extension cards and rendered the author as a non-interactive span so no interactive control is nested inside another (the author link remains in the modal). - color-contrast (WCAG 1.4.3): gave .btn-primary explicit white text with an AA-compliant hover (#7326d6), and bumped the dark-theme secondary text gray (--sl-color-gray-3) to #84849c (5.32:1) so ToC / meta / footer text passes. Adds a checked-in regression guardrail: - website/scripts/a11y-audit.mjs runs axe-core over all routes in both themes against the production build and fails on critical/serious violations only (moderate/minor are reported but non-blocking). Transitions/animations are disabled before sampling so axe measures settled, steady-state colors instead of mid-theme-transition frames. - Adds npm scripts (website:a11y at the root, a11y in website/) and README docs. The matching Build Website CI step is proposed in the PR description (omitted from this commit because the authoring token lacks workflow scope). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package.json | 1 + website/README.md | 8 + website/package-lock.json | 163 +++----- website/package.json | 5 + website/scripts/a11y-audit.mjs | 359 ++++++++++++++++++ website/src/pages/contributors.astro | 2 +- .../pages/learning-hub/cookbook/index.astro | 2 +- website/src/pages/tools.astro | 2 +- website/src/scripts/pages/card-render.ts | 4 +- .../src/scripts/pages/extensions-render.ts | 14 +- website/src/styles/global.css | 6 +- website/src/styles/starlight-overrides.css | 2 +- 12 files changed, 446 insertions(+), 122 deletions(-) create mode 100644 website/scripts/a11y-audit.mjs diff --git a/package.json b/package.json index 871fe7708..17ac84fb7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "website:data": "node ./eng/generate-website-data.mjs", "website:dev": "npm run website:data && npm run --prefix website dev", "website:build": "npm run build && npm run website:data && npm run --prefix website build", + "website:a11y": "npm run website:build && npm run --prefix website a11y", "website:preview": "npm run --prefix website preview" }, "repository": { diff --git a/website/README.md b/website/README.md index f64dccb39..350f2e70d 100644 --- a/website/README.md +++ b/website/README.md @@ -12,6 +12,14 @@ npm run website:dev # generate data + start the dev server npm run website:build # full production build ``` +## Accessibility + +The website has an automated axe-core + Playwright audit. Run it locally with `npm run website:a11y` from the repository root, or run `npm run a11y` from `website/` after building `dist` first. + +CI blocks on critical and serious violations. Minor and moderate best-practice issues are reported as non-blocking. + +Authoring conventions: resource cards use `div[role="listitem"]` wrappers, not `
`; only add `role="list"` to containers whose direct children are list items; do not nest interactive controls inside another focusable element; `.btn-primary` and ToC links must meet WCAG AA (4.5:1) contrast in both light and dark themes. + ## Social preview cards (LinkedIn, etc.) Shared links render as large preview cards driven by Open Graph / Twitter meta tags. diff --git a/website/package-lock.json b/website/package-lock.json index 0890879b4..57f5bd13a 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -18,6 +18,10 @@ "jszip": "^3.10.1", "marked": "^18.0.2", "shiki": "^4.0.2" + }, + "devDependencies": { + "axe-core": "^4.10.2", + "playwright": "^1.49.0" } }, "node_modules/@astrojs/compiler-binding": { @@ -79,9 +83,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -98,9 +99,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -117,9 +115,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -136,9 +131,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -454,9 +446,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -469,9 +458,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -484,9 +470,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -499,9 +482,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -1182,9 +1162,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1201,9 +1178,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1220,9 +1194,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1239,9 +1210,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1258,9 +1226,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1277,9 +1242,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1296,9 +1258,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1315,9 +1274,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1334,9 +1290,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1359,9 +1312,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1384,9 +1334,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1409,9 +1356,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1434,9 +1378,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1459,9 +1400,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1484,9 +1422,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1509,9 +1444,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1863,9 +1795,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1882,9 +1811,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1901,9 +1827,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1920,9 +1843,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1939,9 +1859,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1958,9 +1875,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2477,6 +2391,16 @@ "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -4212,9 +4136,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4235,9 +4156,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4258,9 +4176,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4281,9 +4196,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5762,6 +5674,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/website/package.json b/website/package.json index e4e5a18d3..a9e3aa9a1 100644 --- a/website/package.json +++ b/website/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "astro dev", "build": "astro build", + "a11y": "node ./scripts/a11y-audit.mjs", "preview": "astro preview", "astro": "astro" }, @@ -27,5 +28,9 @@ "jszip": "^3.10.1", "marked": "^18.0.2", "shiki": "^4.0.2" + }, + "devDependencies": { + "axe-core": "^4.10.2", + "playwright": "^1.49.0" } } diff --git a/website/scripts/a11y-audit.mjs b/website/scripts/a11y-audit.mjs new file mode 100644 index 000000000..4f055611e --- /dev/null +++ b/website/scripts/a11y-audit.mjs @@ -0,0 +1,359 @@ +import { execFile, spawn } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +const require = createRequire(import.meta.url); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const websiteDir = path.resolve(scriptDir, '..'); +const distDir = path.join(websiteDir, 'dist'); +const axeMainPath = require.resolve('axe-core'); +const axeSourcePath = path.join(path.dirname(axeMainPath), 'axe.min.js'); + +const routes = [ + '/', + '/agents/', + '/instructions/', + '/skills/', + '/hooks/', + '/workflows/', + '/extensions/', + '/plugins/', + '/tools/', + '/contributors/', + '/learning-hub/cookbook/', + '/learning-hub/github-copilot-app/', +]; + +const themes = ['dark', 'light']; +const blockingImpacts = new Set(['critical', 'serious']); +const serverTimeoutMs = 60_000; +const fetchTimeoutMs = 2_000; + +async function main() { + const port = getPort(); + const providedBaseUrl = process.env.A11Y_BASE_URL?.trim(); + const baseUrl = providedBaseUrl || `http://localhost:${port}/`; + let previewServer; + let browser; + + try { + await access(axeSourcePath); + + if (!providedBaseUrl) { + await access(distDir); + previewServer = startPreviewServer(port); + } + + await waitForServer(new URL('/', baseUrl).toString(), previewServer); + + browser = await chromium.launch(); + const violations = await auditSite(browser, baseUrl); + printReport(violations); + + // Gate only critical and serious violations; moderate/minor findings are reported but non-blocking. + const blockingCount = violations.filter((violation) => blockingImpacts.has(violation.impact)).length; + const nonBlockingCount = violations.length - blockingCount; + + console.log( + `\nSummary: ${blockingCount} blocking (critical/serious), ${nonBlockingCount} non-blocking (moderate/minor) violation(s).`, + ); + + if (blockingCount > 0) { + process.exitCode = 1; + console.error(`Accessibility audit FAILED — ${blockingCount} blocking violation(s)`); + } else { + process.exitCode = 0; + console.log('Accessibility audit PASSED (no critical/serious violations)'); + } + } catch (error) { + process.exitCode = 1; + console.error(`Accessibility audit ERROR — ${error instanceof Error ? error.message : String(error)}`); + } finally { + await closeBrowser(browser); + await stopPreviewServer(previewServer); + } +} + +function getPort() { + const port = Number.parseInt(process.env.A11Y_PORT || '4322', 10); + + if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Invalid A11Y_PORT value: ${process.env.A11Y_PORT}`); + } + + return port; +} + +function startPreviewServer(port) { + const child = spawn('npx', ['astro', 'preview', '--port', String(port), '--host'], { + cwd: websiteDir, + detached: process.platform !== 'win32', + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const server = { + child, + exited: false, + exitCode: null, + signal: null, + spawnError: null, + logs: [], + }; + + const rememberOutput = (chunk) => { + const lines = String(chunk) + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean); + + server.logs.push(...lines); + + if (server.logs.length > 60) { + server.logs.splice(0, server.logs.length - 60); + } + }; + + child.stdout.on('data', rememberOutput); + child.stderr.on('data', rememberOutput); + child.on('error', (error) => { + server.spawnError = error; + rememberOutput(`Failed to start preview server: ${error.message}`); + }); + child.on('exit', (code, signal) => { + server.exited = true; + server.exitCode = code; + server.signal = signal; + }); + + return server; +} + +async function waitForServer(url, server) { + const deadline = Date.now() + serverTimeoutMs; + let lastError; + + while (Date.now() < deadline) { + if (server?.spawnError) { + throw new Error(`${server.spawnError.message}${formatServerLogs(server)}`); + } + + if (server?.exited) { + throw new Error( + `Astro preview exited before it was ready (code ${server.exitCode ?? 'null'}, signal ${ + server.signal ?? 'null' + }).${formatServerLogs(server)}`, + ); + } + + try { + const response = await fetchWithTimeout(url); + + if (response.ok) { + return; + } + + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + + await delay(500); + } + + throw new Error( + `Timed out after ${serverTimeoutMs / 1000}s waiting for ${url}.${ + lastError ? ` Last error: ${lastError.message}` : '' + }${formatServerLogs(server)}`, + ); +} + +async function fetchWithTimeout(url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); + + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +async function auditSite(browser, baseUrl) { + const page = await browser.newPage(); + const violations = []; + + for (const route of routes) { + for (const theme of themes) { + const url = new URL(route, baseUrl).toString(); + + // 'load' (not 'networkidle') keeps the audit robust: cards are server-rendered + // into the initial HTML, and lazy images / search-index requests can otherwise + // keep the network busy indefinitely and stall navigation. + await page.goto(url, { waitUntil: 'load', timeout: 45_000 }); + await page.waitForTimeout(500); + + // Disable CSS transitions/animations before switching themes. Theme changes animate + // background/text colors over ~0.2s; if axe runs mid-transition it samples intermediate + // colors (e.g. a dark card background bleeding through a light page) and reports + // false-positive contrast violations that no real user ever sees. Killing transitions + // makes axe measure the settled, steady-state colors — the actual conformance target. + await page.addStyleTag({ + content: '*,*::before,*::after{transition:none!important;animation:none!important;transition-duration:0s!important;animation-duration:0s!important;}', + }); + + // Force both theme modes so persisted user preference logic cannot hide regressions. + await page.evaluate((selectedTheme) => { + document.documentElement.setAttribute('data-theme', selectedTheme); + localStorage.setItem('awesome-copilot-theme', selectedTheme); + }, theme); + + // Let the forced theme settle (style recalc / reflow) before sampling colors. + await page.waitForTimeout(150); + + await page.addScriptTag({ path: axeSourcePath }); + + const results = await page.evaluate(async () => await axe.run(document, { resultTypes: ['violations'] })); + + for (const violation of results.violations) { + violations.push({ + route, + theme, + id: violation.id, + impact: violation.impact ?? 'unknown', + help: violation.help, + helpUrl: violation.helpUrl, + nodeCount: violation.nodes.length, + }); + } + } + } + + await page.close(); + return violations; +} + +function printReport(violations) { + console.log(`Audited ${routes.length} route(s) across ${themes.length} theme(s).`); + + if (violations.length === 0) { + console.log('\nNo axe violations found.'); + return; + } + + const groupedViolations = new Map(); + + for (const violation of violations) { + const pageTheme = `${violation.route} [${violation.theme}]`; + const pageViolations = groupedViolations.get(pageTheme) || []; + + pageViolations.push(violation); + groupedViolations.set(pageTheme, pageViolations); + } + + for (const [pageTheme, pageViolations] of groupedViolations) { + console.log(`\n${pageTheme}`); + + for (const violation of pageViolations) { + const gate = blockingImpacts.has(violation.impact) ? 'BLOCKING' : 'NON-BLOCKING'; + + console.log(` - ${violation.id} (${violation.impact}, ${gate})`); + console.log(` ${violation.help}`); + console.log(` ${violation.helpUrl}`); + console.log(` Nodes: ${violation.nodeCount}`); + } + } +} + +async function closeBrowser(browser) { + if (!browser) { + return; + } + + try { + await browser.close(); + } catch (error) { + process.exitCode = 1; + console.error(`Failed to close browser cleanly: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function stopPreviewServer(server) { + if (!server || server.exited || !server.child.pid) { + return; + } + + await new Promise((resolve) => { + let resolved = false; + const finish = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(); + } + }; + + const timeout = setTimeout(() => { + forceKill(server.child); + finish(); + }, 5_000); + + server.child.once('exit', finish); + + if (process.platform === 'win32') { + execFile('taskkill', ['/pid', String(server.child.pid), '/T', '/F'], (error) => { + if (error && !server.exited) { + server.child.kill(); + } + }); + return; + } + + try { + process.kill(-server.child.pid, 'SIGTERM'); + } catch { + server.child.kill('SIGTERM'); + } + }); +} + +function forceKill(child) { + if (!child.pid) { + return; + } + + try { + if (process.platform === 'win32') { + child.kill(); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch { + try { + child.kill('SIGKILL'); + } catch { + // The process already exited. + } + } +} + +function formatServerLogs(server) { + if (!server?.logs.length) { + return ''; + } + + return `\n\nAstro preview output:\n${server.logs.join('\n')}`; +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +await main(); diff --git a/website/src/pages/contributors.astro b/website/src/pages/contributors.astro index fe77a4fd3..32f1b11b0 100644 --- a/website/src/pages/contributors.astro +++ b/website/src/pages/contributors.astro @@ -9,7 +9,7 @@ import PageHeader from '../components/PageHeader.astro';
-
+
diff --git a/website/src/pages/learning-hub/cookbook/index.astro b/website/src/pages/learning-hub/cookbook/index.astro index 5af427f5b..b2825f7df 100644 --- a/website/src/pages/learning-hub/cookbook/index.astro +++ b/website/src/pages/learning-hub/cookbook/index.astro @@ -53,7 +53,7 @@ const languageOptions = Array.from(
{getRecipeResultsCountText(samplesData.totalRecipes, samplesData.totalRecipes)}
-
+
diff --git a/website/src/pages/tools.astro b/website/src/pages/tools.astro index c859bb6c7..69b568146 100644 --- a/website/src/pages/tools.astro +++ b/website/src/pages/tools.astro @@ -53,7 +53,7 @@ const initialItems = sortTools(
-
+

More Tools Coming Soon

diff --git a/website/src/scripts/pages/card-render.ts b/website/src/scripts/pages/card-render.ts index c2c0b054a..928300ba1 100644 --- a/website/src/scripts/pages/card-render.ts +++ b/website/src/scripts/pages/card-render.ts @@ -36,7 +36,7 @@ export function renderSharedCardHtml(item: SharedCardRenderItem): string { : "resource-item"; return ` -
+
${item.actionsHtml ? `
${item.actionsHtml}
` : ""} -
+
`; } diff --git a/website/src/scripts/pages/extensions-render.ts b/website/src/scripts/pages/extensions-render.ts index 8172cef56..508291188 100644 --- a/website/src/scripts/pages/extensions-render.ts +++ b/website/src/scripts/pages/extensions-render.ts @@ -3,7 +3,6 @@ import { getGitHubHandle, getGitHubUrl, getLastUpdatedHtml, - sanitizeUrl, } from "../utils"; import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render"; @@ -106,15 +105,9 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string { ? getGitHubHandle(authorUrl, authorName) : authorName || ""; const authorHtml = authorName - ? `by ${ - authorUrl - ? `${escapeHtml(authorHandle)}` - : escapeHtml(authorName) - }` + ? `by ${escapeHtml(authorHandle || authorName)}` : ""; const metaHtml = ` @@ -148,7 +141,6 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string { infoExtraHtml, metaHtml, actionsHtml, - tabIndex: 0, articleAttributes: { id: item.id, "data-extension-id": item.id, diff --git a/website/src/styles/global.css b/website/src/styles/global.css index c297e4caa..82f20c2ee 100644 --- a/website/src/styles/global.css +++ b/website/src/styles/global.css @@ -662,16 +662,16 @@ body:has(#main-content) { .btn-primary { background: var(--color-accent); - color: var(--sl-color-text-invert) !important; + color: #fff !important; border-color: transparent; font-weight: 600; } .btn-primary:hover { - background: var(--color-accent-hover); + background: #7326d6; transform: translateY(-1px); box-shadow: var(--shadow-glow); - color: white; + color: #fff; } .btn-secondary { diff --git a/website/src/styles/starlight-overrides.css b/website/src/styles/starlight-overrides.css index 6497d0ce6..9d59529d2 100644 --- a/website/src/styles/starlight-overrides.css +++ b/website/src/styles/starlight-overrides.css @@ -11,7 +11,7 @@ --sl-color-white: #f0f0f5; --sl-color-gray-1: #d0d0da; --sl-color-gray-2: #9898a6; - --sl-color-gray-3: #60607a; + --sl-color-gray-3: #84849c; --sl-color-gray-4: #30304a; --sl-color-gray-5: #1a1a2e; --sl-color-gray-6: #0d0d12; From f1da81d3559b0bfa077ce2178b9bf78d72c6b0ff Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:33:31 -0500 Subject: [PATCH 2/3] fix(website): harden a11y audit per PR review - Fail fast when a route navigation returns a non-2xx response, so the guardrail can't silently pass against a broken/missing route (page.goto resolves even for 4xx/5xx). - Launch the Astro preview server via `node ` instead of `spawn(..., { shell: true })`. Removing the shell layer keeps signal delivery / detached-PGID shutdown predictable; resolving Astro's bin and running it with process.execPath also avoids the EINVAL that modern Node raises when spawning the npx.cmd shim without a shell on Windows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/scripts/a11y-audit.mjs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/website/scripts/a11y-audit.mjs b/website/scripts/a11y-audit.mjs index 4f055611e..e37dbcaf8 100644 --- a/website/scripts/a11y-audit.mjs +++ b/website/scripts/a11y-audit.mjs @@ -14,6 +14,15 @@ const distDir = path.join(websiteDir, 'dist'); const axeMainPath = require.resolve('axe-core'); const axeSourcePath = path.join(path.dirname(axeMainPath), 'axe.min.js'); +// Resolve Astro's CLI entry point so the preview server can be launched with `node ` +// directly — no shell and no `npx` shim. This keeps signal delivery / detached-PGID shutdown +// predictable (see startPreviewServer) and works identically on Windows and POSIX (spawning a +// .cmd shim without a shell throws EINVAL on modern Node). +const astroPackageJsonPath = require.resolve('astro/package.json'); +const astroBinField = require(astroPackageJsonPath).bin; +const astroBinRelative = typeof astroBinField === 'string' ? astroBinField : astroBinField.astro; +const astroBinPath = path.join(path.dirname(astroPackageJsonPath), astroBinRelative); + const routes = [ '/', '/agents/', @@ -90,10 +99,14 @@ function getPort() { } function startPreviewServer(port) { - const child = spawn('npx', ['astro', 'preview', '--port', String(port), '--host'], { + // Launch `node preview` directly — no shell layer and no npx shim. A shell + // (shell:true) spawns an extra intermediate process that makes signal delivery / PGID-based + // shutdown (detached + process.kill(-pid) below) unreliable, and spawning the npx.cmd shim + // without a shell throws EINVAL on modern Node for Windows. Invoking the resolved bin with + // process.execPath sidesteps both problems on every platform. + const child = spawn(process.execPath, [astroBinPath, 'preview', '--port', String(port), '--host'], { cwd: websiteDir, detached: process.platform !== 'win32', - shell: true, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -195,7 +208,19 @@ async function auditSite(browser, baseUrl) { // 'load' (not 'networkidle') keeps the audit robust: cards are server-rendered // into the initial HTML, and lazy images / search-index requests can otherwise // keep the network busy indefinitely and stall navigation. - await page.goto(url, { waitUntil: 'load', timeout: 45_000 }); + const response = await page.goto(url, { waitUntil: 'load', timeout: 45_000 }); + + // Fail fast on broken/missing routes. page.goto() resolves even for 4xx/5xx responses, + // so without this check axe would run against an error page and the guardrail could + // "pass" while silently masking a broken route. + if (!response) { + throw new Error(`No navigation response for ${url} (theme: ${theme}).`); + } + + if (!response.ok()) { + throw new Error(`Route ${url} (theme: ${theme}) returned HTTP ${response.status()}.`); + } + await page.waitForTimeout(500); // Disable CSS transitions/animations before switching themes. Theme changes animate From 26ed8fa15ed11a7c01909073f3e4ecfd9001f3e7 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:54:57 -0500 Subject: [PATCH 3/3] feat(website): redesign UX across landing and all listing pages Reimagine the site with a modern, cohesive dark-tech language while preserving IA, routes, and copy voice. Header & theme - Replace the hero "View the repository on GitHub" text link with a far-right GitHub mark in the top nav. - Convert the 3-state theme pill into a single cycling icon toggle with an a11y label that reflects the active state. - Fix mobile nav cramping and title clipping. Landing - Recompose the hero and category cards; fix the mobile count overlap and add the missing category accent bar. Listing pages (agents, instructions, skills, hooks, workflows, plugins, tools, extensions) - Introduce a shared resource-card renderer (resource-card.ts) and a generic listing controller (listing-controller.ts) so every page uses one card system and one real, always-visible filter bar (search + sort + facet chips) instead of the broken
toggle. - Give each page a purpose-built identity: category accent, type icon, and per-type facts (model/tools for agents, author/keywords for extensions with icon thumbnails, features for tools, etc.). - Headings now use the landing category accent colors. Verified: astro build passes; axe a11y audit passes (12 routes x 2 themes, 0 critical/serious); desktop + mobile, dark + light checked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/astro.config.mjs | 8 +- website/src/components/PageHeader.astro | 42 +- website/src/components/ThemeToggle.astro | 143 ++-- website/src/pages/agents.astro | 66 +- website/src/pages/extensions.astro | 60 +- website/src/pages/hooks.astro | 75 +- website/src/pages/index.astro | 21 +- website/src/pages/instructions.astro | 70 +- website/src/pages/plugins.astro | 58 +- website/src/pages/skills.astro | 68 +- website/src/pages/tools.astro | 230 +++---- website/src/pages/workflows.astro | 72 +- website/src/scripts/pages/agents-render.ts | 227 ++++-- website/src/scripts/pages/agents.ts | 122 +++- .../src/scripts/pages/extensions-render.ts | 192 +++--- website/src/scripts/pages/extensions.ts | 354 ++-------- website/src/scripts/pages/hooks-render.ts | 115 ++-- website/src/scripts/pages/hooks.ts | 221 ++---- .../src/scripts/pages/instructions-render.ts | 117 +++- website/src/scripts/pages/instructions.ts | 187 +---- .../src/scripts/pages/listing-controller.ts | 245 +++++++ website/src/scripts/pages/plugins-render.ts | 126 ++-- website/src/scripts/pages/plugins.ts | 246 ++----- website/src/scripts/pages/resource-card.ts | 150 ++++ website/src/scripts/pages/skills-render.ts | 122 ++-- website/src/scripts/pages/skills.ts | 177 ++--- website/src/scripts/pages/tools-render.ts | 241 +++---- website/src/scripts/pages/tools.ts | 251 +++---- website/src/scripts/pages/workflows-render.ts | 101 ++- website/src/scripts/pages/workflows.ts | 196 +----- website/src/styles/global.css | 651 ++++++++++++++++-- website/src/styles/starlight-overrides.css | 54 +- 32 files changed, 2756 insertions(+), 2252 deletions(-) create mode 100644 website/src/scripts/pages/listing-controller.ts create mode 100644 website/src/scripts/pages/resource-card.ts diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 1df5c300e..75205b0d8 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -24,7 +24,13 @@ export default defineConfig({ title: "Awesome GitHub Copilot", favicon: "/images/favicon.svg", description: siteDescription, - social: [], + social: [ + { + icon: "github", + label: "View on GitHub", + href: "https://github.com/github/awesome-copilot", + }, + ], head: [ { tag: "meta", diff --git a/website/src/components/PageHeader.astro b/website/src/components/PageHeader.astro index 4719267af..ad87a7b95 100644 --- a/website/src/components/PageHeader.astro +++ b/website/src/components/PageHeader.astro @@ -1,23 +1,26 @@ --- import Icon from './Icon.astro'; +type Accent = 'ai' | 'docs' | 'power' | 'automation' | 'extension' | 'dev' | 'learn'; + interface Props { title: string; description: string; icon?: 'robot' | 'document' | 'lightning' | 'hook' | 'workflow' | 'plug' | 'wrench' | 'book'; + accent?: Accent; } -const { title, description, icon } = Astro.props; +const { title, description, icon, accent } = Astro.props; const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md'; --- -