feat(FN-4254): improve mission manager list accessibility

- Make mission and interview list rows keyboard-reachable buttons with descriptive aria labels
- Handle Enter and Space activation while preventing nested control bubbling from triggering row actions
- Replace targeted MissionManager sizing literals with design tokens and add regression coverage for tokenized CSS
This commit is contained in:
Fusion
2026-05-13 08:55:28 -07:00
committed by gsxdsm
parent 8936dbbe3a
commit ea8582c9a4
3 changed files with 143 additions and 19 deletions

View File

@@ -80,7 +80,7 @@
}
.mission-manager__title {
font-size: 16px;
font-size: var(--space-lg);
font-weight: 600;
margin: 0;
color: var(--text);
@@ -164,7 +164,7 @@
}
.mission-manager__sidebar {
width: 300px;
width: calc(var(--space-lg) * 18.75);
min-width: 0;
flex-shrink: 0;
display: flex;
@@ -347,21 +347,20 @@
}
/* ── Status Badge ── */
/* Fine-grained badge paddings stay pixel-specific for compact legibility. */
.mission-status-badge {
display: inline-flex;
align-items: center;
font-size: 11px;
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
font-weight: 600;
text-transform: capitalize;
padding: 2px 8px;
padding: calc(var(--space-xs) / 2) var(--space-sm);
border-radius: var(--radius-pill);
white-space: nowrap;
}
.mission-status-badge--sm {
font-size: 10px;
padding: 1px 6px;
font-size: calc(var(--space-sm) + (var(--space-xs) / 2));
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4));
}
/* ── Shared Controls ── */
@@ -734,6 +733,15 @@
border-color: var(--text-dim);
}
.mission-list__item:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.mission-list__item:active {
transform: scale(0.99);
}
.mission-list__item--selected {
border-color: var(--todo);
box-shadow: inset 0 0 0 1px var(--todo);
@@ -950,7 +958,7 @@
}
.mission-detail__title {
font-size: 18px;
font-size: calc(var(--space-lg) + (var(--space-xs) / 2));
font-weight: 600;
color: var(--text);
margin: 0;
@@ -974,10 +982,10 @@
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 11px;
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--color-success);
background: var(--meta-badge-bg);
padding: 2px 8px;
padding: calc(var(--space-xs) / 2) var(--space-sm);
border-radius: var(--radius-pill);
}
@@ -1124,8 +1132,8 @@
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px 8px;
font-size: 11px;
padding: calc(var(--space-xs) / 2) var(--space-sm);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
min-height: 0;
}
@@ -1230,8 +1238,8 @@
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
padding: 2px 8px;
font-size: 11px;
padding: calc(var(--space-xs) / 2) var(--space-sm);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
text-transform: capitalize;
background: var(--surface);
color: var(--text-muted);
@@ -1495,9 +1503,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 4px;
width: var(--space-lg);
height: var(--space-lg);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
@@ -1677,12 +1685,12 @@
}
.mission-assertion__linked-count {
font-size: 11px;
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-dim);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 1px 6px;
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4));
flex-shrink: 0;
}

View File

@@ -3598,7 +3598,21 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div
key={session.id}
className="mission-list__item mission-list__item--interview"
role="button"
tabIndex={0}
aria-label={`Resume interview ${session.title || "Mission interview"}`}
onClick={() => handleResumeInterviewSession(session.id)}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) return;
if (event.key === "Enter") {
handleResumeInterviewSession(session.id);
return;
}
if (event.key === " ") {
event.preventDefault();
handleResumeInterviewSession(session.id);
}
}}
>
<div className="mission-list__item-content">
<div className="mission-list__item-header">
@@ -3662,7 +3676,22 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div
key={m.id}
className={`mission-list__item ${isSelected ? "mission-list__item--selected" : ""} ${isInterviewStyle ? "mission-list__item--interview" : ""}`}
role="button"
tabIndex={0}
aria-label={`Open mission ${m.title}`}
aria-pressed={isSelected}
onClick={() => handleSelectMission(mission)}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) return;
if (event.key === "Enter") {
handleSelectMission(mission);
return;
}
if (event.key === " ") {
event.preventDefault();
handleSelectMission(mission);
}
}}
>
<div className="mission-list__item-content">
<div className="mission-list__item-header">

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import { MissionManager } from "../MissionManager";
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
/**
* MissionManager layout reference (post FN-3136):
@@ -4067,6 +4068,92 @@ describe("MissionManager", () => {
});
});
describe("mission list row interactions", () => {
it("renders mission and interview rows as keyboard-reachable buttons with labels", async () => {
mockFetchMissionInterviewDrafts.mockResolvedValue([
{
id: "S-001",
title: "Auth interview",
status: "awaiting_input",
projectId: null,
hasConversation: true,
updatedAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
const missionRow = await screen.findByRole("button", { name: "Open mission Build Auth System" });
const interviewRow = await screen.findByRole("button", { name: "Resume interview Auth interview" });
expect(missionRow).toHaveAttribute("tabindex", "0");
expect(missionRow).toHaveAttribute("aria-pressed", "false");
expect(interviewRow).toHaveAttribute("tabindex", "0");
});
it("activates rows from keyboard and prevents bubbling from interview row actions", async () => {
mockFetchMissionInterviewDrafts.mockResolvedValue([
{
id: "S-002",
title: "Retry interview",
status: "error",
projectId: null,
hasConversation: true,
updatedAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
const fetchMock = createFetchMock();
globalThis.fetch = fetchMock;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
const interviewRow = await screen.findByRole("button", { name: "Resume interview Retry interview" });
fireEvent.keyDown(interviewRow, { key: "Enter" });
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("S-002");
});
mockFetchAiSession.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Discard draft" }));
expect(mockFetchAiSession).not.toHaveBeenCalled();
const missionRow = await screen.findByRole("button", { name: "Open mission Build Auth System" });
const missionDetailFetchesBefore = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001"),
).length;
const spaceEvent = fireEvent.keyDown(missionRow, { key: " " });
expect(spaceEvent).toBe(false);
await waitFor(() => {
const missionDetailFetchesAfter = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001"),
).length;
expect(missionDetailFetchesAfter).toBe(missionDetailFetchesBefore + 1);
});
});
});
describe("MissionManager tokenized sizing regression", () => {
it("does not retain targeted hardcoded px literals in MissionManager selectors", async () => {
const css = await loadAllAppCssBaseOnly();
expect(css).not.toMatch(/\.mission-manager__title\s*\{[^}]*font-size:\s*16px/i);
expect(css).not.toMatch(/\.mission-manager__sidebar\s*\{[^}]*width:\s*300px/i);
expect(css).not.toMatch(/\.mission-status-badge\s*\{[^}]*font-size:\s*11px/i);
expect(css).not.toMatch(/\.mission-status-badge\s*\{[^}]*padding:\s*2px\s+8px/i);
expect(css).not.toMatch(/\.mission-status-badge--sm\s*\{[^}]*font-size:\s*10px/i);
expect(css).not.toMatch(/\.mission-status-badge--sm\s*\{[^}]*padding:\s*1px\s+6px/i);
expect(css).not.toMatch(/\.mission-detail__title\s*\{[^}]*font-size:\s*18px/i);
expect(css).not.toMatch(/\.mission-event__type\s*\{[^}]*font-size:\s*11px/i);
expect(css).not.toMatch(/\.mission-event__type\s*\{[^}]*padding:\s*2px\s+8px/i);
expect(css).not.toMatch(/\.mission-plan-state-indicator\s*\{[^}]*width:\s*16px/i);
expect(css).not.toMatch(/\.mission-plan-state-indicator\s*\{[^}]*height:\s*16px/i);
expect(css).not.toMatch(/\.mission-plan-state-indicator\s*\{[^}]*border-radius:\s*4px/i);
});
});
describe("two-panel layout test IDs", () => {
it("renders sidebar and empty detail pane on desktop via test IDs", async () => {
globalThis.fetch = createFetchMock();