diff --git a/docs/accessibility.md b/docs/accessibility.md index 9047724..6245df6 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -1,7 +1,8 @@ # Accessibility review -This review covers keyboard-only use, screen reader structure, and 200% zoom -(Stellar Wave issues #108, #109, #110, #111). It records what was verified +This review covers keyboard-only use, screen reader structure, 200% zoom, +link cues, heading hierarchy, command summaries, and high contrast mode +(Stellar Wave issues #104, #105, #106, #107, #108, #109, #110, #111). It records what was verified automatically, what was fixed, and what still needs a manual pass. ## What is verified automatically @@ -11,11 +12,15 @@ automatically, what was fixed, and what still needs a manual pass. | Keyboard | Skip link is first in tab order and moves focus to the page content | `tests/visual/accessibility.spec.ts` | | Keyboard | Every focusable control shows an outline on keyboard focus | `tests/visual/accessibility.spec.ts` | | Keyboard | Workflow tabs respond to arrow, Home and End keys | `src/App.test.tsx`, `tests/visual/accessibility.spec.ts` | +| Non-color cues | Links have underline, offset, and cues on hover/active states beyond color (#104) | `src/styles.css`, `src/App.test.tsx` | +| Screen reader | Module cards follow logical heading hierarchy h2 -> h3 -> h4 with labelled regions (#105) | `src/App.test.tsx` | +| Screen reader | Active command and option settings are summarized in an accessible live region (#106) | `src/App.test.tsx` | | Keyboard | Each command in the limits example is reachable in order with Tab | `tests/visual/accessibility.spec.ts` | | Screen reader | Landmarks, a single `h1`, and named navigation links | `tests/visual/accessibility.spec.ts` | | Screen reader | Command selector is a labelled group of toggle buttons | `src/App.test.tsx`, `tests/visual/accessibility.spec.ts` | | Screen reader | Copy results are announced through a polite live region | `src/App.test.tsx`, `tests/visual/accessibility.spec.ts` | | Screen reader | Audit prerequisites are a labelled fieldset of checkboxes, each with its own accessible name | `src/App.test.tsx` | +| High contrast | Controls, buttons, tabs, inputs, and cards retain boundaries in high-contrast/forced-colors modes (#107) | `src/styles.css`, `src/App.test.tsx` | | Zoom | No horizontal page scroll and no clipped controls at 640px and 320px CSS widths | `tests/visual/accessibility.spec.ts` | These checks read the accessibility tree and computed styles. They do not @@ -52,3 +57,7 @@ browser and screen reader pair per platform. | 8 | The limits workflow needed a discoverable, ready-to-run command reference. | Fixed: added a focusable reference section and a build-and-run example with keyboard-focusable command steps. | `src/App.tsx`, `src/styles.css`, `tests/visual/accessibility.spec.ts` | | 9 | Scrollable `pre` blocks are not keyboard-focusable in every browser, so long lines may be unreachable by keyboard on some browsers. | Open. | `src/styles.css` | | 10 | The stored visual baselines in `tests/visual/__screenshots__` predate the current layout and do not match a fresh render. | Open. Baselines should be regenerated on the CI platform, not locally. | `tests/visual/` | +| 11 | Hover and active link and button states relied solely on color changes (#104). | Fixed: added underlines, offsets, and elevation cues. | `src/styles.css` | +| 12 | Module cards lacked structured heading levels for cards and their workflow groups (#105). | Fixed: established h2 -> h3 -> h4 hierarchy and region grouping. | `src/App.tsx` | +| 13 | Active command parameters lacked a screen-reader accessible summary (#106). | Fixed: added polite live region and focusable summary card. | `src/App.tsx` | +| 14 | Interactive controls and cards lost clear boundaries in high-contrast/forced-colors modes (#107). | Fixed: added forced-colors and prefers-contrast: more styling. | `src/styles.css` | diff --git a/src/App.test.tsx b/src/App.test.tsx index 05fa906..5d52ea7 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -728,6 +728,102 @@ describe("Navigation enhancements (#112, #113, #114, #115)", () => { }); }); +describe("Accessibility enhancements (#104, #105, #106, #107)", () => { + it("provides logical heading hierarchy in modules section and cards (#105)", () => { + render(); + const modulesSection = document.getElementById("modules"); + expect(modulesSection).toHaveAttribute("aria-labelledby", "modules-heading"); + + const modulesHeading = document.getElementById("modules-heading"); + expect(modulesHeading).not.toBeNull(); + expect(modulesHeading).toHaveAttribute("id", "modules-heading"); + + const articles = document.querySelectorAll("article.module-card"); + expect(articles.length).toBeGreaterThan(0); + + articles.forEach((article) => { + const heading = article.querySelector("h3"); + expect(heading).not.toBeNull(); + const headingId = heading?.getAttribute("id"); + expect(headingId).toBeTruthy(); + expect(article).toHaveAttribute("aria-labelledby", headingId); + + const workflowGroup = article.querySelector(".module-card-links"); + expect(workflowGroup).toHaveAttribute("role", "group"); + const h4 = workflowGroup?.querySelector("h4"); + expect(h4).not.toBeNull(); + expect(h4).toHaveTextContent("Available workflows"); + expect(h4).toHaveClass("sr-only"); + }); + }); + + it("provides empty state heading when no modules match search filter (#105)", () => { + render(); + const searchInput = screen.getByLabelText("Search modules by name or description"); + fireEvent.change(searchInput, { target: { value: "nonexistent-module-xyz" } }); + + const emptyStatus = screen.getByRole("status"); + expect(emptyStatus).toHaveAttribute("aria-labelledby", "module-empty-heading"); + + const emptyHeading = screen.getByRole("heading", { level: 3, name: /No modules match/i }); + expect(emptyHeading).toHaveAttribute("id", "module-empty-heading"); + }); + + it("provides screen-reader summary of active command configuration (#106)", () => { + render(); + const summaryRegion = screen.getByRole("region", { + name: "Active command configuration summary", + }); + expect(summaryRegion).toBeInTheDocument(); + expect(summaryRegion).toHaveAttribute("tabindex", "0"); + + const srSummary = screen.getByTestId("active-command-sr-summary"); + expect(srSummary).toHaveAttribute("aria-live", "polite"); + expect(srSummary).toHaveAttribute("aria-atomic", "true"); + expect(srSummary).toHaveTextContent("Active coverage command settings"); + + // Switching workflow updates the SR summary + const limitsTab = screen.getByRole("button", { name: "limits" }); + fireEvent.click(limitsTab); + + expect(srSummary).toHaveTextContent("Active limits command settings"); + }); + + it("includes live region announcement for generated command output (#106)", () => { + render(); + const generatedSrSummary = screen.getByTestId("generated-command-sr-summary"); + expect(generatedSrSummary).toHaveAttribute("aria-live", "polite"); + expect(generatedSrSummary).toHaveTextContent("soroban-testkit coverage"); + }); + + it("supports non-color cues on links and active states (#104)", () => { + render(); + const navLinks = screen.getAllByRole("link", { name: /^(Install|Modules|Commands)$/ }); + const commandsLink = navLinks.find((l) => l.textContent === "Commands"); + expect(commandsLink).toBeDefined(); + + fireEvent.click(commandsLink!); + expect(commandsLink).toHaveAttribute("aria-current", "location"); + + // All module card quick links have non-empty accessible text + const moduleLinks = document.querySelectorAll(".module-card-links a"); + expect(moduleLinks.length).toBeGreaterThan(0); + moduleLinks.forEach((link) => { + expect(link.textContent?.trim().length).toBeGreaterThan(0); + }); + }); + + it("ensures controls and summary cards have container boundaries for high contrast (#107)", () => { + render(); + const summaryCard = document.querySelector(".summary-card"); + expect(summaryCard).not.toBeNull(); + expect(summaryCard).toHaveClass("summary-card"); + + const moduleCards = document.querySelectorAll(".module-card"); + expect(moduleCards.length).toBeGreaterThan(0); + }); +}); + // #117 — mobile navigation menu describe("Mobile navigation menu", () => { it("renders the mobile menu toggle button", () => { diff --git a/src/App.tsx b/src/App.tsx index d179133..b503372 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -818,7 +818,7 @@ export default function App() { -
+
{selectedModuleData ? (
{/* #136 — visible page title for deep-linked views */} @@ -830,7 +830,7 @@ export default function App() { {selectedModuleData.name}

{selectedModuleData.category}

-

{selectedModuleData.name}

+

{selectedModuleData.name}

{selectedModuleData.description}

-
+
+

Available workflows

Command builder @@ -943,11 +944,11 @@ export default function App() { ))}
) : ( -
+
-

{moduleFilter ? `No modules match “${moduleFilter}”` : "No modules match your filters"}

+

{moduleFilter ? `No modules match “${moduleFilter}”` : "No modules match your filters"}

{categoryFilter ? `No testing modules in the ${categoryFilter} category match this search.` @@ -1268,8 +1269,22 @@ export default function App() {

-
+

Command summary

+
+ Active {command} command settings: {summaryRows.map((row) => `${row.label} is ${row.value}`).join(", ")}. +

{command}

{summaryRows.map((row) => ( @@ -1495,7 +1510,14 @@ export default function App() { )}
-
+
+
+ Active generated command: {generated} +
{highlightCommand(generated)} {/* #146 — download action for generated shell script */} diff --git a/src/styles.css b/src/styles.css index 430c51a..f3fda81 100644 --- a/src/styles.css +++ b/src/styles.css @@ -85,7 +85,20 @@ * { box-sizing: border-box; } html { scroll-behavior: smooth; scroll-padding-top: 78px; } body { margin: 0; min-width: 320px; background: radial-gradient(circle at 75% 8%, var(--color-canvas-gradient) 0, transparent 28rem), var(--color-canvas); } -a { color: inherit; text-decoration: none; } +a { + color: inherit; + text-decoration: none; + transition: color .15s ease, text-decoration .15s ease, transform .15s ease; +} +a:hover { + text-decoration: underline; + text-underline-offset: 4px; +} +a:active { + text-decoration: underline; + text-underline-offset: 4px; + text-decoration-thickness: 2px; +} button, input, select { font: inherit; } main { max-width: 1440px; margin: auto; overflow: hidden; } @@ -129,8 +142,28 @@ nav { max-width: 200px; overflow: hidden; text-overflow: ellipsis; + text-decoration: none; +} +.nav-links a:hover { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 6px; + text-decoration-thickness: 1.5px; +} +.nav-links a:active { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 6px; + text-decoration-thickness: 2.5px; +} +.nav-links a[aria-current="location"], +.nav-links a[aria-current="page"] { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 6px; + text-decoration-thickness: 2px; + font-weight: 600; } -.nav-links a:hover { color: var(--color-accent); } /* Hero Section */ .hero { min-height: 680px; padding: var(--space-section) var(--page-padding) 80px; display: grid; grid-template-columns: 1.05fr .95fr; align-items: center; gap: 7vw; } @@ -138,7 +171,24 @@ nav { h1 { margin: var(--space-xl) 0 var(--space-3xl); font-size: clamp(3.4rem, 6.5vw, 7rem); line-height: .96; letter-spacing: -.065em; } .lede { max-width: 650px; color: var(--color-text-secondary); font-size: 1.15rem; line-height: 1.75; } .actions { display: flex; gap: var(--space-md); margin-top: 38px; } -.button { padding: 13px 19px; border-radius: var(--radius-base); font-weight: 600; font-size: .9rem; cursor: pointer; } +.button { + padding: 13px 19px; + border-radius: var(--radius-base); + font-weight: 600; + font-size: .9rem; + cursor: pointer; + transition: transform .15s ease, box-shadow .15s ease, background-color .15s ease; +} +.button:hover, a.button:hover { + text-decoration: none; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} +.button:active, a.button:active { + text-decoration: none; + transform: translateY(0); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} .primary { color: var(--color-accent-text-dark); background: var(--color-accent); border: 0; } .secondary { border: 1px solid var(--color-border-strong); background: transparent; color: var(--color-text-primary); } @@ -198,7 +248,23 @@ h2 { margin: var(--space-md) 0; font-size: clamp(2rem, 4vw, 4rem); letter-spacin .module-card-select:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; } .module-card-links, .category-filter-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-md); } .module-card-links { margin-top: var(--space-xl); } -.module-card-links a { color: var(--color-accent); font-size: .75rem; } +.module-card-links a { + display: inline-flex; + align-items: center; + font: 500 .75rem var(--font-mono); + color: var(--color-accent); + padding: 2px 0; + text-decoration: none; +} +.module-card-links a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} +.module-card-links a:active { + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-thickness: 2px; +} .category-filter-bar { margin: var(--space-xl) 0; } .category-chip { padding: var(--space-xs) var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-round); color: var(--color-text-secondary); background: transparent; cursor: pointer; } .category-chip.active { border-color: var(--color-accent); color: var(--color-accent); background: var(--color-accent-subtle); } @@ -267,6 +333,54 @@ h2 { margin: var(--space-md) 0; font-size: clamp(2rem, 4vw, 4rem); letter-spacin .description { min-height: 48px; } .builder { padding: var(--space-5xl); background: var(--color-canvas); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); } +/* Command Summary Card */ +.summary-card { + margin-top: var(--space-3xl); + padding: var(--space-xl); + background: var(--color-surface-card); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); +} +.summary-card:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} +.summary-workflow { + font: 700 1rem var(--font-mono); + color: var(--color-accent); + margin: var(--space-xs) 0 var(--space-md); + text-transform: uppercase; + letter-spacing: .06em; +} +.summary-settings { + display: grid; + gap: var(--space-xs); + margin: 0 0 var(--space-md); + padding: 0; +} +.summary-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: .82rem; + padding: 4px 0; + border-bottom: 1px solid var(--color-border-subtle); +} +.summary-row dt { + color: var(--color-text-secondary); +} +.summary-row dd { + margin: 0; + font-family: var(--font-mono); + color: var(--color-text-primary); +} +.summary-note { + margin: 0; + font-size: .75rem; + color: var(--color-text-muted); + line-height: 1.5; +} + /* Field & Required/Optional distinction */ label { display: grid; gap: 9px; margin-bottom: var(--space-2xl); color: var(--color-text-label); font-size: .78rem; } .field-header { @@ -448,6 +562,136 @@ footer { display: flex; justify-content: space-between; padding: 34px var(--page } } +/* High-contrast and forced-colors mode support without losing control boundaries (#107) */ +@media (forced-colors: active) { + /* Control boundaries for interactive buttons */ + .button, + button, + .primary, + .secondary { + border: 2px solid ButtonBorder !important; + outline: 1px solid transparent; + } + + .primary { + background: Highlight !important; + color: HighlightText !important; + border-color: Highlight !important; + } + + .button:hover, + button:hover { + border-color: Highlight !important; + } + + .button:focus-visible, + button:focus-visible { + outline: 3px solid Highlight !important; + outline-offset: 2px !important; + } + + /* Form inputs and controls preserve explicit field borders */ + input, + select, + textarea, + .module-filter-input { + border: 2px solid FieldBorder !important; + color: FieldText !important; + background: Field !important; + } + + input:focus, + select:focus, + textarea:focus, + .module-filter-input:focus { + border-color: Highlight !important; + outline: 2px solid Highlight !important; + outline-offset: 1px !important; + } + + /* Cards, articles, and regions maintain distinct visible borders */ + .module-grid article, + .summary-card, + .builder, + .terminal, + .guide-card, + .install-guide, + .install-step, + .comparison { + border: 1px solid CanvasText !important; + } + + /* Command tabs / workflow buttons */ + .command-tabs button { + border: 1px solid ButtonBorder !important; + } + + .command-tabs button.active, + .command-tabs button[aria-pressed="true"] { + border: 2px solid Highlight !important; + background: Highlight !important; + color: HighlightText !important; + } + + /* Badges */ + .badge, + .badge-required, + .badge-optional { + border: 1px solid CanvasText !important; + } + + /* Checkboxes */ + .check input[type="checkbox"] { + outline: 1px solid CanvasText !important; + } + + /* Links */ + a:hover, + a:active { + text-decoration: underline !important; + text-decoration-color: Highlight !important; + } + + .nav-links a[aria-current="location"], + .nav-links a[aria-current="page"] { + border-bottom: 2px solid Highlight !important; + } +} + +@media (prefers-contrast: more) { + :root { + --color-border-subtle: #ffffff33; + --color-border-default: #ffffff44; + --color-border-strong: #ffffff66; + --color-border-input: #ffffff55; + --color-border-tab: #ffffff55; + --color-text-secondary: #c2d6ce; + --color-text-muted: #8ea39b; + } + + .button, + button { + border-width: 2px; + } + + input, + select, + textarea { + border-width: 2px; + } + + .module-grid article { + border-width: 1px; + border-color: var(--color-border-strong); + } + + a:hover, + a:active { + text-decoration: underline; + text-decoration-thickness: 2px; + } +} + /* #117 — mobile nav toggle */ .mobile-menu-toggle { display: none;