Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ automatically, what was fixed, and what still needs a manual pass.
| 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` |
| Screen reader | Every form field carries an `aria-describedby` pointing to visible inline help text (#100) | `src/App.test.tsx` |
| Screen reader | Focus moves to the command section heading after a workflow tab switch (#101) | `src/App.test.tsx` |
| Screen reader | Validation errors are announced via an `aria-live="assertive"` region (#102) | `src/App.test.tsx` |
| Screen reader | "Copied" label remains visible for at least 2 000 ms to give AT time to announce it (#103) | `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
Expand Down Expand Up @@ -52,3 +56,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 | Form fields had no programmatic help text, so users relying on screen readers could not learn what each field expects without trial and error. | Fixed (#100): every required and optional input, select, and checkbox now carries an `aria-describedby` id pointing to a visible `.field-help` element. | `src/App.tsx` |
| 12 | Switching workflow tabs left focus on the tab button, so screen reader users had to navigate forwards to find where the form changed. | Fixed (#101): after any tab switch, focus moves to the "From intent to command" `h2`, giving AT users an unambiguous entry point. | `src/App.tsx` |
| 13 | Validation failures were silent; a screen reader user copying a command with an empty required field received no feedback. | Fixed (#102): an `aria-live="assertive"` region announces the first unsatisfied constraint, and the error clears when the field is filled. | `src/App.tsx` |
| 14 | The "Copied" label reset after 1 600 ms, which is shorter than the announcement latency of many screen readers and Braille displays. | Fixed (#103): the reset timeout is now 3 000 ms, giving assistive technology enough time to read the change. | `src/App.tsx` |
194 changes: 194 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1003,3 +1003,197 @@ describe("Session-only command history UI", () => {
});
});
});

// Issue #100 — inline help text associated with each form field
describe("Inline help text for form fields (#100)", () => {
it("associates help text with the path input via aria-describedby", () => {
render(<App />);
const pathInput = screen.getByRole("textbox", { name: /project or contract path/i });
const describedBy = pathInput.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
const helpEl = document.getElementById(describedBy!);
expect(helpEl).not.toBeNull();
expect(helpEl!.textContent).toMatch(/path/i);
});

it("associates help text with the shell select via aria-describedby", () => {
render(<App />);
const shellSelect = screen.getByRole("combobox", { name: /shell target/i });
const describedBy = shellSelect.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
const helpEl = document.getElementById(describedBy!);
expect(helpEl).not.toBeNull();
expect(helpEl!.textContent?.length).toBeGreaterThan(0);
});

it("associates help text with the format select when coverage workflow is active", () => {
render(<App />);
const formatSelect = screen.getByRole("combobox", { name: /report format/i });
const describedBy = formatSelect.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
const helpEl = document.getElementById(describedBy!);
expect(helpEl).not.toBeNull();
expect(helpEl!.textContent?.length).toBeGreaterThan(0);
});

it("associates help text with fn and ramp inputs in the limits workflow", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "limits" }));
const fnInput = screen.getByRole("textbox", { name: /^function$/i });
expect(fnInput.getAttribute("aria-describedby")).toBe("fn-help");
expect(document.getElementById("fn-help")).not.toBeNull();
const rampInput = screen.getByRole("textbox", { name: /ramp parameter/i });
expect(rampInput.getAttribute("aria-describedby")).toBe("ramp-help");
expect(document.getElementById("ramp-help")).not.toBeNull();
});
});

// Issue #101 — focus moves to the command section heading after a mode switch
describe("Focus moves to workflow heading on mode switch (#101)", () => {
it("focuses the command section heading when switching to the limits workflow", () => {
render(<App />);
const limitsButton = screen.getByRole("button", { name: "limits" });
fireEvent.click(limitsButton);
const heading = screen.getByRole("heading", { name: /from intent to command/i });
expect(heading).toHaveFocus();
});

it("focuses the command section heading when switching back to coverage", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "limits" }));
fireEvent.click(screen.getByRole("button", { name: "coverage" }));
const heading = screen.getByRole("heading", { name: /from intent to command/i });
expect(heading).toHaveFocus();
});

it("focuses the command section heading when switching to audit", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "audit" }));
const heading = screen.getByRole("heading", { name: /from intent to command/i });
expect(heading).toHaveFocus();
});
});

// Issue #102 — validation errors announced through a live region
describe("Validation errors announced via live region (#102)", () => {
const originalClipboard = navigator.clipboard;

afterEach(() => {
Object.defineProperty(navigator, "clipboard", {
value: originalClipboard,
configurable: true,
});
vi.restoreAllMocks();
});

it("renders an assertive live region for validation errors", () => {
render(<App />);
const alerts = document.querySelectorAll('[aria-live="assertive"]');
expect(alerts.length).toBeGreaterThan(0);
});

it("announces an error when copy is attempted with an empty path", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});

render(<App />);

// Clear the path field
const pathInput = screen.getByRole("textbox", { name: /project or contract path/i });
fireEvent.change(pathInput, { target: { value: "" } });

fireEvent.click(screen.getByRole("button", { name: /^Copy$/ }));

await waitFor(() => {
const alertRegion = document.querySelector('[aria-live="assertive"]');
expect(alertRegion?.textContent).toMatch(/path.*required/i);
});
// Clipboard should not be called when validation fails
expect(writeText).not.toHaveBeenCalled();
});

it("clears the error message when the path field is filled in", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});

render(<App />);
const pathInput = screen.getByRole("textbox", { name: /project or contract path/i });
fireEvent.change(pathInput, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: /^Copy$/ }));

await waitFor(() => {
const alertRegion = document.querySelector('[aria-live="assertive"]');
expect(alertRegion?.textContent).toMatch(/required/i);
});

fireEvent.change(pathInput, { target: { value: "." } });

await waitFor(() => {
const alertRegion = document.querySelector('[aria-live="assertive"]');
expect(alertRegion?.textContent).toBe("");
});
});
});

// Issue #103 — copied status persists long enough for assistive technology
describe("Copied status persistence for assistive technology (#103)", () => {
const originalClipboard = navigator.clipboard;

afterEach(() => {
Object.defineProperty(navigator, "clipboard", {
value: originalClipboard,
configurable: true,
});
vi.restoreAllMocks();
});

it("shows the Copied label immediately after copy and resets after 3 s", async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});

render(<App />);

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Copy$/ }));
});

expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();

// Still present at 2 s — not yet reset
act(() => { vi.advanceTimersByTime(2000); });
expect(screen.queryByRole("button", { name: "Copied" })).toBeInTheDocument();

// Reverted after 3 s
act(() => { vi.advanceTimersByTime(1100); });
expect(screen.queryByRole("button", { name: /^Copy$/ })).toBeInTheDocument();

vi.useRealTimers();
});

it("announces the copy result in the polite live region", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});

render(<App />);

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Copy$/ }));
});

const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion?.textContent).toMatch(/copied/i);
});
});
Loading