diff --git a/docs/components/MilestoneCreationForm.md b/docs/components/MilestoneCreationForm.md
index 4d2cc6c0..738fffa8 100644
--- a/docs/components/MilestoneCreationForm.md
+++ b/docs/components/MilestoneCreationForm.md
@@ -52,7 +52,8 @@ For example, `Frontend Development - Sprint 1` becomes an id shaped like `fronte
- The modal root uses `role="dialog"`, `aria-modal="true"`, and `aria-labelledby="create-milestone-title"`.
- `ErrorSummary` exposes validation failures through `role="alert"` and links each error back to its field id.
- `FormField` applies required markers, `aria-invalid`, `aria-describedby`, and error styling for invalid fields.
-- All controls are native form inputs, selects, or buttons, so keyboard operation follows browser defaults.
+- All controls are native form inputs, selects, or buttons, so keyboard operation follows browser defaults (Tab, Enter, Space).
+- Cancel and Submit expose a visible `focus-visible` outline so keyboard users can track focus without layout changes.
- The Cancel button is `type="button"` so it never submits the form.
## Parent Contract
diff --git a/docs/components/MilestonesList.md b/docs/components/MilestonesList.md
index a3f34e6f..5cc0bd4a 100644
--- a/docs/components/MilestonesList.md
+++ b/docs/components/MilestonesList.md
@@ -16,7 +16,9 @@ An accessible, dismissible banner is surfaced above the milestones list when the
- **Keyboard accessibility**:
- The banner container has `role="status"` to announce updates to screen readers.
- Links inside the banner target the specific milestone element ID (`#milestone-${id}`) to allow users to jump or scroll directly to the milestone card.
+ - Dismiss and in-list links use `focus-visible` rings so keyboard focus is visible without changing layout.
- Focus is programmatically restored to the scrollable milestones list container when the banner is dismissed, preventing loss of keyboard focus (WCAG 2.1.1).
+ - Enter and Space activate the dismiss control; Tab reaches links, dismiss, then the scroll region.
## Implementation Details
diff --git a/src/app/milestones/page.tsx b/src/app/milestones/page.tsx
index 2af82fe3..314358b7 100644
--- a/src/app/milestones/page.tsx
+++ b/src/app/milestones/page.tsx
@@ -178,7 +178,7 @@ const MilestonesContent: React.FC = () => {
type="button"
onClick={handleDismissSampleBanner}
aria-label="Dismiss sample data notice"
- className="text-blue-500 hover:text-blue-700"
+ className="rounded-sm text-blue-500 hover:text-blue-700 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
×
diff --git a/src/components/MilestonesList.tsx b/src/components/MilestonesList.tsx
index 41aab3d9..91258a4d 100644
--- a/src/components/MilestonesList.tsx
+++ b/src/components/MilestonesList.tsx
@@ -144,7 +144,7 @@ const MilestonesList = ({ milestones, contractCurrency }: MilestonesListProps) =
type="button"
onClick={handleDismiss}
aria-label="Dismiss reminder"
- className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-amber-600 hover:bg-amber-100 hover:text-amber-800 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-1 dark:text-amber-400 dark:hover:bg-amber-500/10 dark:hover:text-amber-200 transition-colors"
+ className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-amber-600 hover:bg-amber-100 hover:text-amber-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-1 dark:text-amber-400 dark:hover:bg-amber-500/10 dark:hover:text-amber-200 transition-colors"
>
×
diff --git a/src/components/__tests__/MilestonesKeyboard.test.tsx b/src/components/__tests__/MilestonesKeyboard.test.tsx
new file mode 100644
index 00000000..48bef653
--- /dev/null
+++ b/src/components/__tests__/MilestonesKeyboard.test.tsx
@@ -0,0 +1,360 @@
+/**
+ * #618 — Milestones keyboard operability
+ *
+ * Covers:
+ * - Visible focus styles on every interactive milestones control
+ * - Enter / Space activation
+ * - Logical tab order (filter → Add → due-soon → scroll region)
+ * - Edge cases: Cancel via keyboard, filter arrow keys, dismiss focus move
+ */
+
+import React from 'react';
+import { act, render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { axe } from 'jest-axe';
+import MilestonesPage from '@/app/milestones/page';
+import { MilestoneCreationForm } from '@/components/milestones/MilestoneCreationForm';
+import MilestoneFilter from '@/components/milestones/MilestoneFilter';
+import MilestonesList from '@/components/MilestonesList';
+import { listMilestones, saveMilestone } from '@/lib/repository';
+import { resetCache as resetSafeStorageCache } from '@/lib/safeStorage';
+import type { Milestone } from '@/types/domain';
+
+const mockSearchParams = {
+ get: jest.fn(() => null),
+ toString: jest.fn(() => ''),
+};
+const mockReplace = jest.fn();
+
+jest.mock('next/navigation', () => ({
+ useSearchParams: () => mockSearchParams,
+ useRouter: () => ({ replace: mockReplace, push: jest.fn(), prefetch: jest.fn() }),
+}));
+
+jest.mock('@/lib/repository', () => ({
+ listMilestones: jest.fn(),
+ saveMilestone: jest.fn(),
+}));
+
+const mockedListMilestones = jest.mocked(listMilestones);
+const mockedSaveMilestone = jest.mocked(saveMilestone);
+
+const FOCUS_OUTLINE = [
+ 'focus-visible:outline',
+ 'focus-visible:outline-4',
+ 'focus-visible:outline-offset-2',
+ 'focus-visible:outline-blue-500',
+] as const;
+
+const persisted: Milestone[] = [
+ {
+ id: 'kb-1',
+ title: 'Keyboard Kickoff',
+ status: 'Pending',
+ payout: 1000,
+ currency: 'USD',
+ dueDate: '2026-07-25',
+ },
+ {
+ id: 'kb-2',
+ title: 'Keyboard Review',
+ status: 'Active',
+ payout: 2000,
+ currency: 'USD',
+ dueDate: '2026-08-10',
+ },
+];
+
+async function renderMilestonesPage() {
+ const result = render();
+ await act(async () => {});
+ return result;
+}
+
+beforeEach(() => {
+ // Pin "today" so kb-1 falls inside the due-soon window (7 days).
+ jest.useFakeTimers({
+ now: new Date('2026-07-22T12:00:00Z'),
+ advanceTimers: true,
+ });
+ mockedListMilestones.mockReturnValue(persisted);
+ mockedSaveMilestone.mockImplementation(() => {});
+ window.localStorage.clear();
+ resetSafeStorageCache();
+ mockSearchParams.get.mockReturnValue(null);
+ mockSearchParams.toString.mockReturnValue('');
+ mockReplace.mockReset();
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+ jest.clearAllMocks();
+ window.localStorage.clear();
+ resetSafeStorageCache();
+});
+
+// ---------------------------------------------------------------------------
+// Visible focus styles
+// ---------------------------------------------------------------------------
+
+describe('milestones keyboard — visible focus styles', () => {
+ it('Add Milestone toolbar button exposes focus-visible outline classes', async () => {
+ await renderMilestonesPage();
+
+ const addBtn = screen.getByRole('button', { name: /^add milestone$/i });
+ FOCUS_OUTLINE.forEach((cls) => expect(addBtn).toHaveClass(cls));
+ });
+
+ it('sample banner dismiss × exposes focus-visible outline classes', async () => {
+ mockedListMilestones.mockReturnValue([]);
+ await renderMilestonesPage();
+
+ const dismiss = screen.getByRole('button', {
+ name: /dismiss sample data notice/i,
+ });
+ FOCUS_OUTLINE.forEach((cls) => expect(dismiss).toHaveClass(cls));
+ });
+
+ it('sample banner Start from scratch exposes focus-visible outline classes', async () => {
+ mockedListMilestones.mockReturnValue([]);
+ await renderMilestonesPage();
+
+ const cta = screen.getByTestId('start-from-scratch-btn');
+ FOCUS_OUTLINE.forEach((cls) => expect(cta).toHaveClass(cls));
+ });
+
+ it('MilestoneCreationForm Cancel and Submit expose focus-visible outlines', () => {
+ render(
+ ,
+ );
+
+ const cancel = screen.getByRole('button', { name: /cancel/i });
+ const submit = screen.getByRole('button', { name: /add milestone/i });
+
+ FOCUS_OUTLINE.forEach((cls) => {
+ expect(cancel).toHaveClass(cls);
+ expect(submit).toHaveClass(cls);
+ });
+ });
+
+ it('MilestoneFilter option labels expose focus-within ring classes', () => {
+ const { container } = render(
+ ,
+ );
+
+ const labels = container.querySelectorAll('label');
+ expect(labels.length).toBeGreaterThan(0);
+ labels.forEach((label) => {
+ expect(label.className).toMatch(/focus-within:ring-2/);
+ expect(label.className).toMatch(/focus-within:ring-indigo-500/);
+ });
+ });
+
+ it('due-soon dismiss and in-list links expose focus-visible ring classes', () => {
+ render();
+
+ const dismiss = screen.getByRole('button', { name: /dismiss reminder/i });
+ expect(dismiss).toHaveClass('focus-visible:ring-2');
+ expect(dismiss).toHaveClass('focus-visible:ring-amber-500');
+ expect(dismiss.className).not.toMatch(/(?:^|\s)focus:ring-2(?:\s|$)/);
+
+ const link = screen.getByRole('link', { name: /keyboard kickoff/i });
+ expect(link).toHaveClass('focus-visible:ring-2');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Enter / Space activation
+// ---------------------------------------------------------------------------
+
+describe('milestones keyboard — Enter/Space activation', () => {
+ it('Enter on Add Milestone opens the creation dialog', async () => {
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ await renderMilestonesPage();
+
+ const addBtn = screen.getByRole('button', { name: /^add milestone$/i });
+ addBtn.focus();
+ await user.keyboard('{Enter}');
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('Space on Add Milestone opens the creation dialog', async () => {
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ await renderMilestonesPage();
+
+ const addBtn = screen.getByRole('button', { name: /^add milestone$/i });
+ addBtn.focus();
+ await user.keyboard('[Space]');
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('Enter on sample dismiss × hides the banner', async () => {
+ mockedListMilestones.mockReturnValue([]);
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ await renderMilestonesPage();
+
+ const dismiss = screen.getByRole('button', {
+ name: /dismiss sample data notice/i,
+ });
+ dismiss.focus();
+ await user.keyboard('{Enter}');
+
+ expect(screen.queryByTestId('sample-data-banner')).not.toBeInTheDocument();
+ });
+
+ it('Space on sample dismiss × hides the banner', async () => {
+ mockedListMilestones.mockReturnValue([]);
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ await renderMilestonesPage();
+
+ const dismiss = screen.getByRole('button', {
+ name: /dismiss sample data notice/i,
+ });
+ dismiss.focus();
+ await user.keyboard('[Space]');
+
+ expect(screen.queryByTestId('sample-data-banner')).not.toBeInTheDocument();
+ });
+
+ it('Enter on Cancel closes the creation dialog', async () => {
+ const onCancel = jest.fn();
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ render();
+
+ const cancel = screen.getByRole('button', { name: /cancel/i });
+ cancel.focus();
+ await user.keyboard('{Enter}');
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('Space on Cancel closes the creation dialog', async () => {
+ const onCancel = jest.fn();
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ render();
+
+ const cancel = screen.getByRole('button', { name: /cancel/i });
+ cancel.focus();
+ await user.keyboard('[Space]');
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('Space selects a MilestoneFilter radio option', async () => {
+ const onChange = jest.fn();
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ render(
+ ,
+ );
+
+ const pending = screen.getByRole('radio', { name: 'Pending' });
+ pending.focus();
+ await user.keyboard('[Space]');
+
+ expect(onChange).toHaveBeenCalledWith('Pending');
+ });
+
+ it('Enter/Space dismisses the due-soon reminder and moves focus to the list', async () => {
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ const { container } = render();
+
+ const dismiss = screen.getByRole('button', { name: /dismiss reminder/i });
+ dismiss.focus();
+ await user.keyboard('{Enter}');
+
+ expect(
+ screen.queryByRole('button', { name: /dismiss reminder/i }),
+ ).not.toBeInTheDocument();
+
+ const region = container.querySelector('[role="region"]');
+ expect(region).toHaveFocus();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tab order
+// ---------------------------------------------------------------------------
+
+describe('milestones keyboard — logical tab order', () => {
+ it('tabs from filter radios to Add Milestone to due-soon controls to scroll region', async () => {
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ const { container } = await renderMilestonesPage();
+
+ const allRadio = screen.getByRole('radio', { name: 'All' });
+ allRadio.focus();
+ expect(allRadio).toHaveFocus();
+
+ // Within a radiogroup only the checked radio is in the tab order;
+ // next Tab leaves the group to Add Milestone.
+ await user.tab();
+ expect(screen.getByRole('button', { name: /^add milestone$/i })).toHaveFocus();
+
+ await user.tab();
+ expect(screen.getByRole('link', { name: /keyboard kickoff/i })).toHaveFocus();
+
+ await user.tab();
+ expect(screen.getByRole('button', { name: /dismiss reminder/i })).toHaveFocus();
+
+ await user.tab();
+ const region = container.querySelector('[role="region"]');
+ expect(region).toHaveFocus();
+ });
+
+ it('creation form action buttons remain in tab order and can receive focus', async () => {
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ render(
+ ,
+ );
+
+ const dialog = screen.getByRole('dialog');
+ const cancel = within(dialog).getByRole('button', { name: /cancel/i });
+ const submit = within(dialog).getByRole('button', { name: /add milestone/i });
+
+ expect(cancel).not.toHaveAttribute('tabindex', '-1');
+ expect(submit).not.toHaveAttribute('tabindex', '-1');
+
+ cancel.focus();
+ expect(cancel).toHaveFocus();
+ await user.tab();
+ expect(submit).toHaveFocus();
+ });
+
+ it('arrow keys move selection within the MilestoneFilter radiogroup', async () => {
+ const onChange = jest.fn();
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ render(
+ ,
+ );
+
+ screen.getByRole('radio', { name: 'All' }).focus();
+ await user.keyboard('{ArrowRight}');
+
+ expect(onChange).toHaveBeenCalledWith('Active');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Axe
+// ---------------------------------------------------------------------------
+
+describe('milestones keyboard — axe', () => {
+ beforeEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('page with toolbar controls has no axe violations', async () => {
+ mockedListMilestones.mockReturnValue(persisted);
+ const { container } = render();
+ await act(async () => {});
+ expect(await axe(container)).toHaveNoViolations();
+ }, 20000);
+
+ it('creation form actions have no axe violations', async () => {
+ const { container } = render(
+ ,
+ );
+ expect(await axe(container)).toHaveNoViolations();
+ }, 20000);
+});
diff --git a/src/components/milestones/MilestoneCreationForm.tsx b/src/components/milestones/MilestoneCreationForm.tsx
index 2d7d22d9..adc58d63 100644
--- a/src/components/milestones/MilestoneCreationForm.tsx
+++ b/src/components/milestones/MilestoneCreationForm.tsx
@@ -255,13 +255,13 @@ export const MilestoneCreationForm: React.FC = ({