From 2c728b419df981ffde033b40533443542d243850 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:21:40 -0700 Subject: [PATCH 1/5] Improve mobile touch targets Apply shared mobile-only 44px hit areas across the verified frontend controls and add responsive bounding-box coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c18414c-86c8-49b5-9557-c6e93c29c02c --- frontend/e2e/touch-targets.spec.ts | 559 ++++++++++++++++++ .../components/Chat/ChatInputArea.styles.ts | 11 + .../src/components/Chat/ChatInputArea.tsx | 8 +- .../src/components/Chat/ChatWindow.styles.ts | 5 + frontend/src/components/Chat/ChatWindow.tsx | 1 + .../Chat/ConversationPanel.styles.ts | 12 + .../src/components/Chat/ConversationPanel.tsx | 5 +- .../src/components/Chat/MessageList.styles.ts | 10 + frontend/src/components/Chat/MessageList.tsx | 12 +- .../components/Config/TargetConfig.styles.ts | 11 +- .../src/components/Config/TargetConfig.tsx | 1 + .../components/Config/TargetTable.styles.ts | 15 + .../src/components/Config/TargetTable.tsx | 3 + .../History/AttackHistory.styles.ts | 27 + .../src/components/History/AttackHistory.tsx | 2 + .../src/components/History/AttackTable.tsx | 1 + .../components/History/HistoryFiltersBar.tsx | 1 + .../components/History/HistoryPagination.tsx | 2 + frontend/src/components/Home/Home.styles.ts | 5 + frontend/src/components/Home/Home.tsx | 3 + .../src/components/Labels/LabelsBar.styles.ts | 2 + .../src/components/Tour/TourTooltip.styles.ts | 4 + frontend/src/components/Tour/TourTooltip.tsx | 7 +- frontend/src/styles/touchTargets.ts | 15 + 24 files changed, 710 insertions(+), 12 deletions(-) create mode 100644 frontend/e2e/touch-targets.spec.ts create mode 100644 frontend/src/styles/touchTargets.ts diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts new file mode 100644 index 0000000000..c87d2750e7 --- /dev/null +++ b/frontend/e2e/touch-targets.spec.ts @@ -0,0 +1,559 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { makeTarget } from "./_targets"; + +const MOBILE_VIEWPORT = { width: 390, height: 844 }; +const DESKTOP_VIEWPORT = { width: 1280, height: 800 }; +const MINIMUM_TOUCH_TARGET_SIZE = 44; + +const TARGETS = [ + makeTarget({ + target_registry_name: "mobile-round-robin", + target_type: "RoundRobinTarget", + model_name: "mobile-router", + capabilities: { + supports_multi_turn: true, + supports_system_prompt: true, + supported_input_modalities: ["text", "image_path"], + supported_output_modalities: ["text"], + }, + target_specific_params: { weights: [1, 1] }, + inner_targets: [ + { + target_registry_name: "mobile-inner-primary", + target_type: "OpenAIChatTarget", + endpoint: "https://primary.example.test", + model_name: "gpt-4o-primary", + }, + { + target_registry_name: "mobile-inner-secondary", + target_type: "OpenAIChatTarget", + endpoint: "https://secondary.example.test", + model_name: "gpt-4o-secondary", + }, + ], + }), + makeTarget({ + target_registry_name: "mobile-chat-target", + target_type: "OpenAIChatTarget", + endpoint: "https://chat.example.test", + model_name: "gpt-4o-mobile", + capabilities: { + supports_multi_turn: true, + supports_system_prompt: true, + supported_input_modalities: ["text", "image_path"], + supported_output_modalities: ["text"], + }, + }), +]; + +const MESSAGES = [ + { + turn_number: 1, + role: "user", + created_at: "2026-07-22T13:10:00.000Z", + message_pieces: [ + { + id: "mobile-user-piece", + original_value_data_type: "text", + converted_value_data_type: "text", + original_value: "Assess this deterministic mobile prompt", + converted_value: "Assess this deterministic mobile prompt", + scores: [], + response_error: "none", + }, + ], + }, + { + turn_number: 1, + role: "assistant", + created_at: "2026-07-22T13:10:01.000Z", + message_pieces: [ + { + id: "mobile-assistant-piece", + original_value_data_type: "text", + converted_value_data_type: "text", + original_value: "Deterministic assistant response for touch-target tests.", + converted_value: "Deterministic assistant response for touch-target tests.", + scores: [], + response_error: "none", + }, + ], + }, +]; + +const ATTACK_SUMMARY = { + attack_result_id: "home-attack-001", + conversation_id: "home-conversation-001", + attack_type: "PromptSendingAttack", + target: { + target_type: "OpenAIChatTarget", + endpoint: "https://chat.example.test", + model_name: "gpt-4o-mobile", + }, + converters: [], + outcome: "success", + labels: { operator: "mobile_operator", operation: "touch_targets" }, + message_count: 2, + related_conversation_ids: [], + created_at: "2026-07-22T13:07:00.000Z", + updated_at: "2026-07-22T13:08:00.000Z", + last_message_preview: "Deterministic recent operation", +}; + +function jsonResponse(body: unknown) { + return { + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }; +} + +async function installTouchTargetMocks(page: Page): Promise { + await page.route("**/api/**", async (route) => { + const request = route.request(); + const apiPath = new URL(request.url()).pathname.replace(/^\/api/, ""); + const method = request.method(); + + if (apiPath === "/health") { + await route.fulfill(jsonResponse({ status: "healthy" })); + return; + } + if (apiPath === "/auth/config") { + await route.fulfill( + jsonResponse({ clientId: "", tenantId: "", allowedGroupIds: "" }) + ); + return; + } + if (apiPath === "/version") { + await route.fulfill( + jsonResponse({ + version: "touch-target-test", + display: "touch-target-test", + default_labels: { + operator: "mobile_operator", + operation: "touch_targets", + }, + }) + ); + return; + } + if (apiPath === "/labels") { + await route.fulfill( + jsonResponse({ + source: "attacks", + labels: { + operator: ["mobile_operator"], + operation: ["touch_targets"], + campaign: ["mobile_audit"], + }, + }) + ); + return; + } + if (apiPath === "/targets/catalog") { + await route.fulfill( + jsonResponse({ + items: [ + { + target_type: "OpenAIChatTarget", + parameters: [], + supported_auth_modes: ["api_key"], + }, + { + target_type: "RoundRobinTarget", + parameters: [], + supported_auth_modes: ["api_key"], + }, + ], + }) + ); + return; + } + if (apiPath === "/targets" && method === "GET") { + await route.fulfill( + jsonResponse({ + items: TARGETS, + pagination: { + limit: 200, + has_more: false, + next_cursor: null, + prev_cursor: null, + }, + }) + ); + return; + } + if (apiPath === "/converters/catalog" || apiPath === "/converters") { + await route.fulfill(jsonResponse({ items: [] })); + return; + } + if (apiPath === "/attacks" && method === "GET") { + await route.fulfill( + jsonResponse({ + items: [ATTACK_SUMMARY], + pagination: { + limit: 50, + has_more: false, + next_cursor: null, + prev_cursor: null, + }, + }) + ); + return; + } + if (apiPath === "/attacks" && method === "POST") { + await route.fulfill( + jsonResponse({ + attack_result_id: "mobile-attack-001", + conversation_id: "mobile-conversation-001", + }) + ); + return; + } + if (apiPath === "/attacks/mobile-attack-001") { + await route.fulfill( + jsonResponse({ + attack_result_id: "mobile-attack-001", + attack_type: "PromptSendingAttack", + conversation_id: "mobile-conversation-001", + related_conversation_ids: [], + labels: { + operator: "mobile_operator", + operation: "touch_targets", + }, + target: { + target_type: "RoundRobinTarget", + endpoint: null, + model_name: "mobile-router", + identifier_hash: "mobile-round-robin-hash", + }, + updated_at: "2026-07-22T13:10:01.000Z", + }) + ); + return; + } + if (apiPath === "/attacks/mobile-attack-001/messages") { + await route.fulfill( + method === "POST" + ? jsonResponse({ messages: { messages: MESSAGES } }) + : jsonResponse({ messages: MESSAGES }) + ); + return; + } + if (apiPath === "/attacks/mobile-attack-001/conversations") { + await route.fulfill( + jsonResponse({ + attack_result_id: "mobile-attack-001", + main_conversation_id: "mobile-conversation-001", + conversations: [ + { + conversation_id: "mobile-conversation-001", + message_count: 2, + last_message_preview: "Deterministic assistant response", + created_at: "2026-07-22T13:10:00.000Z", + }, + ], + }) + ); + return; + } + if (apiPath === "/attacks/attack-options") { + await route.fulfill( + jsonResponse({ attack_types: ["PromptSendingAttack"] }) + ); + return; + } + if (apiPath === "/attacks/converter-options") { + await route.fulfill(jsonResponse({ converter_types: [] })); + return; + } + + throw new Error(`Unhandled touch-target API request: ${method} ${apiPath}`); + }); +} + +async function expectMinimumTouchTarget(locator: Locator): Promise { + await expect(locator).toBeVisible(); + const box = await locator.boundingBox(); + if (!box) { + throw new Error("Expected a visible touch target"); + } + expect(box.width).toBeGreaterThanOrEqual(MINIMUM_TOUCH_TARGET_SIZE); + expect(box.height).toBeGreaterThanOrEqual(MINIMUM_TOUCH_TARGET_SIZE); +} + +async function expectMinimumTouchTargets(locator: Locator): Promise { + const count = await locator.count(); + expect(count).toBeGreaterThan(0); + for (let index = 0; index < count; index += 1) { + await expectMinimumTouchTarget(locator.nth(index)); + } +} + +async function expectCompactDesktopTarget(locator: Locator): Promise { + await expect(locator).toBeVisible(); + const box = await locator.boundingBox(); + if (!box) { + throw new Error("Expected a visible desktop target"); + } + expect(box.height).toBeLessThan(MINIMUM_TOUCH_TARGET_SIZE); +} + +async function expectNoDocumentOverflow(page: Page): Promise { + const dimensions = await page.evaluate(() => ({ + viewportWidth: document.documentElement.clientWidth, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + })); + expect(dimensions.documentWidth).toBeLessThanOrEqual( + dimensions.viewportWidth + ); + expect(dimensions.bodyWidth).toBeLessThanOrEqual(dimensions.viewportWidth); +} + +async function startChatWithMessages(page: Page): Promise { + await page.getByRole("button", { name: "Configuration", exact: true }).click(); + await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); + await page.getByRole("button", { name: "Set Active" }).first().click(); + await page.getByRole("button", { name: "Chat", exact: true }).click(); + await page.getByTestId("chat-input").fill( + "Assess this deterministic mobile prompt" + ); + await page.getByTestId("send-message-btn").click(); + await expect( + page.getByText("Deterministic assistant response for touch-target tests.") + ).toBeVisible(); +} + +test.beforeEach(async ({ page }) => { + await installTouchTargetMocks(page); +}); + +test.describe("Mobile touch targets", () => { + test.use({ viewport: MOBILE_VIEWPORT }); + + test("keeps Home, Configuration, and History controls at least 44px", async ({ + page, + }) => { + await page.goto("/"); + await expect(page.getByTestId("home-view")).toBeVisible(); + + await expectMinimumTouchTargets( + page.getByTestId("home-view").locator( + [ + '[data-testid="labels-icon-btn"]', + '[data-testid="home-configure-target-btn"]', + '[data-testid="home-view-all-history-btn"]', + '[data-testid="home-open-attack-home-attack-001"]', + ].join(",") + ) + ); + await expectMinimumTouchTarget( + page.getByRole("button", { name: "Home", exact: true }) + ); + await expectNoDocumentOverflow(page); + + await page + .getByRole("button", { name: "Configuration", exact: true }) + .click(); + await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); + + await expectMinimumTouchTarget( + page.getByRole("button", { name: "Refresh", exact: true }) + ); + await expectMinimumTouchTarget( + page.getByRole("button", { name: "New Target", exact: true }) + ); + await expectMinimumTouchTargets( + page.getByRole("button", { name: "Set Active" }) + ); + await expectMinimumTouchTarget( + page.getByRole("button", { name: "Expand inner targets" }) + ); + await expectMinimumTouchTarget(page.locator("select")); + await expectNoDocumentOverflow(page); + + await page.goto("/history"); + await expect( + page.getByTestId("open-attack-home-attack-001") + ).toBeVisible(); + + await expectMinimumTouchTarget(page.getByTestId("refresh-btn")); + await expectMinimumTouchTargets( + page.locator( + [ + '[data-testid="attack-type-filter"]', + '[data-testid="outcome-filter"]', + '[data-testid="converter-filter"]', + '[data-testid="operator-filter"]', + '[data-testid="operation-filter"]', + '[data-testid="label-filter"]', + ].join(",") + ) + ); + await expectMinimumTouchTargets( + page.getByRole("button", { name: "Open", exact: true }) + ); + + const openAttack = page.getByTestId("open-attack-home-attack-001"); + await openAttack.scrollIntoViewIfNeeded(); + await expectMinimumTouchTarget(openAttack); + await expectMinimumTouchTarget(page.getByTestId("prev-page-btn")); + await expectMinimumTouchTarget(page.getByTestId("next-page-btn")); + await expectNoDocumentOverflow(page); + }); + + test("keeps Chat message, input, and conversation controls at least 44px", async ({ + page, + }) => { + await page.goto("/"); + await startChatWithMessages(page); + + await expectMinimumTouchTargets( + page.locator( + [ + '[data-testid="labels-icon-btn"]', + '[data-testid="toggle-panel-btn"]', + '[data-testid="new-attack-btn"]', + '[aria-label="Attach files"]', + '[data-testid="toggle-converter-panel-btn"]', + '[data-testid="chat-input"]', + '[data-testid="send-message-btn"]', + '[data-testid="copy-to-input-btn-1"]', + '[data-testid="copy-to-new-conv-btn-1"]', + '[data-testid="branch-conv-btn-1"]', + '[data-testid="branch-attack-btn-1"]', + ].join(",") + ) + ); + await expectNoDocumentOverflow(page); + + await page.getByTestId("toggle-panel-btn").click(); + await expect( + page.getByRole("dialog", { name: "Attack Conversations" }) + ).toBeVisible(); + + await expectMinimumTouchTargets( + page.locator( + [ + '[data-testid="new-conversation-btn"]', + '[data-testid="close-panel-btn"]', + '[data-testid="conversation-item-mobile-conversation-001"]', + '[data-testid="star-btn-mobile-conversation-001"]', + ].join(",") + ) + ); + await expectNoDocumentOverflow(page); + }); + + test("keeps every tour action at least 44px without changing containment", async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("start-tour").click(); + + await expect(page.getByText("1 of 5")).toBeVisible(); + await expectMinimumTouchTargets( + page.getByRole("button", { + name: /^(Close|Skip tour|Next)$/, + }) + ); + + for (const step of [2, 3, 4]) { + await page + .getByRole("button", { name: "Next", exact: true }) + .click({ force: true }); + await expect(page.getByText(`${step} of 5`)).toBeVisible(); + await expectMinimumTouchTargets( + page.getByRole("button", { + name: /^(Close|Skip tour|Back|Next)$/, + }) + ); + } + + await page + .getByRole("button", { name: "Next", exact: true }) + .click({ force: true }); + await expect(page.getByText("5 of 5")).toBeVisible(); + await expectMinimumTouchTarget( + page.getByRole("button", { name: "Back", exact: true }) + ); + await expectMinimumTouchTarget( + page.getByRole("button", { name: "Anchors Away!", exact: true }) + ); + }); +}); + +test("preserves compact desktop controls and existing sidebar dimensions", async ({ + page, +}) => { + await page.setViewportSize(DESKTOP_VIEWPORT); + await page.goto("/"); + await expect(page.getByTestId("home-view")).toBeVisible(); + + const sidebarHome = page.getByRole("button", { + name: "Home", + exact: true, + }); + const sidebarBox = await sidebarHome.boundingBox(); + expect(sidebarBox?.width).toBe(44); + expect(sidebarBox?.height).toBe(44); + + await expectCompactDesktopTarget(page.getByTestId("labels-icon-btn")); + await expectCompactDesktopTarget( + page.getByTestId("home-configure-target-btn") + ); + await expectCompactDesktopTarget( + page.getByTestId("home-open-attack-home-attack-001") + ); + + await page + .getByRole("button", { name: "Configuration", exact: true }) + .click(); + await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); + await expectCompactDesktopTarget( + page.getByRole("button", { name: "Refresh", exact: true }) + ); + await expectCompactDesktopTarget(page.locator("select")); + await expectCompactDesktopTarget( + page.getByRole("button", { name: "Set Active" }).first() + ); + await expectCompactDesktopTarget( + page.getByRole("button", { name: "Expand inner targets" }) + ); + + await startChatWithMessages(page); + await expectCompactDesktopTarget(page.getByTestId("toggle-panel-btn")); + await expectCompactDesktopTarget( + page.getByRole("button", { name: "Attach files" }) + ); + await expectCompactDesktopTarget(page.getByTestId("chat-input")); + await expectCompactDesktopTarget(page.getByTestId("copy-to-input-btn-1")); + + await page.getByTestId("toggle-panel-btn").click(); + await expect(page.getByTestId("conversation-panel")).toBeVisible(); + await expectCompactDesktopTarget(page.getByTestId("close-panel-btn")); + await expectCompactDesktopTarget( + page.getByTestId("star-btn-mobile-conversation-001") + ); + await page.getByTestId("close-panel-btn").click(); + + await page.getByRole("button", { name: "Home", exact: true }).click(); + await page.getByTestId("start-tour").click(); + await expect(page.getByText("1 of 5")).toBeVisible(); + await expectCompactDesktopTarget( + page.getByRole("button", { name: "Close", exact: true }) + ); + await expectCompactDesktopTarget( + page.getByRole("button", { name: "Next", exact: true }) + ); + + await page.goto("/history"); + await expect(page.getByTestId("refresh-btn")).toBeVisible(); + await expectCompactDesktopTarget(page.getByTestId("refresh-btn")); + await expectCompactDesktopTarget(page.getByTestId("attack-type-filter")); + const openAttack = page.getByTestId("open-attack-home-attack-001"); + await openAttack.scrollIntoViewIfNeeded(); + await expectCompactDesktopTarget(openAttack); + await expectCompactDesktopTarget(page.getByTestId("next-page-btn")); +}); diff --git a/frontend/src/components/Chat/ChatInputArea.styles.ts b/frontend/src/components/Chat/ChatInputArea.styles.ts index 27aa9c61da..b5d0d17868 100644 --- a/frontend/src/components/Chat/ChatInputArea.styles.ts +++ b/frontend/src/components/Chat/ChatInputArea.styles.ts @@ -1,4 +1,5 @@ import { makeStyles, tokens } from '@fluentui/react-components' +import { mobileTouchTarget, mobileTouchTargetHeight } from '../../styles/touchTargets' export const useChatInputAreaStyles = makeStyles({ root: { @@ -109,6 +110,7 @@ export const useChatInputAreaStyles = makeStyles({ minHeight: '24px', maxHeight: '60vh', overflowY: 'auto', + ...mobileTouchTargetHeight, '::placeholder': { color: tokens.colorNeutralForeground4, }, @@ -133,12 +135,14 @@ export const useChatInputAreaStyles = makeStyles({ padding: 0, borderRadius: '50%', border: `1px solid ${tokens.colorNeutralStroke1}`, + ...mobileTouchTarget, }, dismissBtn: { minWidth: '24px', width: '24px', height: '24px', padding: 0, + ...mobileTouchTarget, }, sendButton: { minWidth: '32px', @@ -146,6 +150,7 @@ export const useChatInputAreaStyles = makeStyles({ height: '32px', padding: 0, borderRadius: '50%', + ...mobileTouchTarget, }, clearConversionButton: { minWidth: '32px', @@ -156,6 +161,7 @@ export const useChatInputAreaStyles = makeStyles({ border: `1px solid ${tokens.colorNeutralStroke1}`, backgroundColor: tokens.colorNeutralBackground1, color: tokens.colorNeutralForeground2, + ...mobileTouchTarget, ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover, color: tokens.colorNeutralForeground1, @@ -227,6 +233,7 @@ export const useChatInputAreaStyles = makeStyles({ whiteSpace: 'pre-wrap', wordBreak: 'break-word', padding: 0, + ...mobileTouchTargetHeight, '::-webkit-scrollbar': { width: '8px', }, @@ -288,6 +295,7 @@ export const useChatInputAreaStyles = makeStyles({ textDecoration: 'none', flexShrink: 0, height: '20px', + ...mobileTouchTarget, ':hover': { backgroundColor: tokens.colorBrandBackgroundHover, color: tokens.colorNeutralForegroundOnBrand, @@ -300,6 +308,9 @@ export const useChatInputAreaStyles = makeStyles({ color: tokens.colorPaletteYellowForeground2, fontSize: tokens.fontSizeBase200, }, + touchTarget: { + ...mobileTouchTarget, + }, originalBadge: { display: 'inline-block', padding: `0 ${tokens.spacingHorizontalXS}`, diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 898d71a273..e2c0721b04 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -33,9 +33,10 @@ interface StatusBannerProps { className: string textClassName: string buttonTestId?: string + buttonClassName?: string } -function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testId, className, textClassName, buttonTestId }: StatusBannerProps) { +function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testId, className, textClassName, buttonTestId, buttonClassName }: StatusBannerProps) { return (
{icon} @@ -44,6 +45,7 @@ function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testI {onButtonClick && buttonText && ( Page {page + 1}
)} {index > 0 && ( - )} {continuous && ( - )} diff --git a/frontend/src/styles/touchTargets.ts b/frontend/src/styles/touchTargets.ts new file mode 100644 index 0000000000..c9357cb879 --- /dev/null +++ b/frontend/src/styles/touchTargets.ts @@ -0,0 +1,15 @@ +export const MOBILE_BREAKPOINT = '@media (max-width: 600px)' +export const MINIMUM_TOUCH_TARGET_SIZE = '44px' + +export const mobileTouchTarget = { + [MOBILE_BREAKPOINT]: { + minWidth: MINIMUM_TOUCH_TARGET_SIZE, + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, +} + +export const mobileTouchTargetHeight = { + [MOBILE_BREAKPOINT]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, +} From d19c0ea8431acd789db8ed07490dd7417ad93771 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:06:34 -0700 Subject: [PATCH 2/5] Address PR review comments on touch targets - touchTargets: key the mobileTouchTarget helpers off @media (pointer: coarse) instead of viewport width, and use 2.75rem (was 44px) so the minimum target scales with the user's text-zoom preference - Apply the touch-target helper to previously uncovered buttons in the same flows: ConverterPanel close, ConverterParams chevron toggle and Browse, and the CreateTargetDialog row delete button - Document the touch-target convention in the frontend style guide Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a26c899-a856-4e2e-bf8f-24de153c0154 --- .github/instructions/frontend-style-guide.instructions.md | 1 + .../Chat/ConverterPanel/ConverterPanel.styles.ts | 4 ++++ .../src/components/Chat/ConverterPanel/ConverterPanel.tsx | 1 + .../src/components/Chat/ConverterPanel/ConverterParams.tsx | 5 +++-- .../src/components/Config/CreateTargetDialog.styles.ts | 4 ++++ frontend/src/components/Config/CreateTargetDialog.tsx | 1 + frontend/src/styles/touchTargets.ts | 7 ++++--- 7 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/instructions/frontend-style-guide.instructions.md b/.github/instructions/frontend-style-guide.instructions.md index f102bb66b3..ae45ff8b20 100644 --- a/.github/instructions/frontend-style-guide.instructions.md +++ b/.github/instructions/frontend-style-guide.instructions.md @@ -545,6 +545,7 @@ myPromise.then((v) => console.log(v)) - Use semantic HTML elements (`
diff --git a/frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx b/frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx index 1607755c76..4f733bb778 100644 --- a/frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx +++ b/frontend/src/components/Chat/ConverterPanel/ConverterParams.tsx @@ -1,4 +1,4 @@ -import { Button, Input, Select, Switch, Text, Tooltip } from '@fluentui/react-components' +import { Button, Input, mergeClasses, Select, Switch, Text, Tooltip } from '@fluentui/react-components' import { ChevronDownRegular, ChevronRightRegular, InfoRegular } from '@fluentui/react-icons' import type { ConverterCatalogEntry, Parameter } from '../../../types' import { useConverterPanelStyles } from './ConverterPanel.styles' @@ -42,6 +42,7 @@ function ParameterFileViewer({ param, value, isMissing, onChange, onBrowse }: Pa appearance="subtle" size="small" onClick={() => onBrowse(param.name)} + className={styles.touchTarget} data-testid={`param-${param.name}-browse`} > Browse @@ -86,7 +87,7 @@ export default function ConverterParams({ converter, paramValues, paramsExpanded size="small" icon={paramsExpanded ? : } onClick={onToggleExpanded} - className={styles.paramsSectionHeader} + className={mergeClasses(styles.paramsSectionHeader, styles.touchTarget)} data-testid="toggle-params-btn" > Parameters diff --git a/frontend/src/components/Config/CreateTargetDialog.styles.ts b/frontend/src/components/Config/CreateTargetDialog.styles.ts index 4c6a50e304..677c1d9b74 100644 --- a/frontend/src/components/Config/CreateTargetDialog.styles.ts +++ b/frontend/src/components/Config/CreateTargetDialog.styles.ts @@ -1,4 +1,5 @@ import { makeStyles, tokens } from '@fluentui/react-components' +import { mobileTouchTarget } from '../../styles/touchTargets' export const useCreateTargetDialogStyles = makeStyles({ dialogSurface: { @@ -112,6 +113,9 @@ export const useCreateTargetDialogStyles = makeStyles({ width: '5rem', minWidth: '5rem', }, + touchTarget: { + ...mobileTouchTarget, + }, weightError: { alignSelf: 'flex-end', marginTop: tokens.spacingVerticalXXS, diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx index a3d30f8565..a8b1a63da3 100644 --- a/frontend/src/components/Config/CreateTargetDialog.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.tsx @@ -560,6 +560,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT icon={} aria-label={`Remove ${sel.registryName}`} onClick={() => removeInnerTarget(sel.registryName)} + className={styles.touchTarget} /> {weightError && ( diff --git a/frontend/src/styles/touchTargets.ts b/frontend/src/styles/touchTargets.ts index c9357cb879..6631cc5284 100644 --- a/frontend/src/styles/touchTargets.ts +++ b/frontend/src/styles/touchTargets.ts @@ -1,15 +1,16 @@ export const MOBILE_BREAKPOINT = '@media (max-width: 600px)' -export const MINIMUM_TOUCH_TARGET_SIZE = '44px' +export const COARSE_POINTER = '@media (pointer: coarse)' +export const MINIMUM_TOUCH_TARGET_SIZE = '2.75rem' export const mobileTouchTarget = { - [MOBILE_BREAKPOINT]: { + [COARSE_POINTER]: { minWidth: MINIMUM_TOUCH_TARGET_SIZE, minHeight: MINIMUM_TOUCH_TARGET_SIZE, }, } export const mobileTouchTargetHeight = { - [MOBILE_BREAKPOINT]: { + [COARSE_POINTER]: { minHeight: MINIMUM_TOUCH_TARGET_SIZE, }, } From 12a0b78aad2687ce18f401c9203627e0b945f9ea Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:33:40 -0700 Subject: [PATCH 3/5] Rename touch-target media queries and enforce coarse-pointer sizing Rename MOBILE_BREAKPOINT -> NARROW_VIEWPORT_QUERY and COARSE_POINTER -> TOUCH_INPUT_QUERY for clarity, key all hit-area sizing on the touch-input query (TargetTable, AttackHistory, TargetConfig), and add hasTouch to the mobile e2e context so (pointer: coarse) matches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a26c899-a856-4e2e-bf8f-24de153c0154 --- .../frontend-style-guide.instructions.md | 1 + frontend/e2e/touch-targets.spec.ts | 2 +- frontend/src/components/Chat/MessageList.styles.ts | 4 ++-- .../src/components/Config/TargetConfig.styles.ts | 7 +++++-- .../src/components/Config/TargetTable.styles.ts | 4 ++-- .../src/components/History/AttackHistory.styles.ts | 6 +++--- frontend/src/styles/touchTargets.ts | 13 +++++++++---- 7 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.github/instructions/frontend-style-guide.instructions.md b/.github/instructions/frontend-style-guide.instructions.md index ae45ff8b20..a1f292c57d 100644 --- a/.github/instructions/frontend-style-guide.instructions.md +++ b/.github/instructions/frontend-style-guide.instructions.md @@ -546,6 +546,7 @@ myPromise.then((v) => console.log(v)) - Add `aria-label` or `aria-describedby` when the visual label is insufficient. - Interactive elements MUST be keyboard-accessible. Use `data-testid` for test selectors, not DOM structure. - Small and icon buttons that are reachable on touch/mobile MUST meet a minimum touch-target size. Apply the `mobileTouchTarget` (or `mobileTouchTargetHeight`) helper from `src/styles/touchTargets` in the component's `makeStyles` and set it on the button's `className`. +- Use `NARROW_VIEWPORT_QUERY` for layout/reflow (wrapping, stacking on small screens) and `TOUCH_INPUT_QUERY` for sizing decisions (touch-target minimums). Never use `NARROW_VIEWPORT_QUERY` for hit-area sizing. ### Comments & Documentation - Use `/** JSDoc */` for documentation that users of the code should read (exported functions, components, interfaces). diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index c87d2750e7..0543cc8a63 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -330,7 +330,7 @@ test.beforeEach(async ({ page }) => { }); test.describe("Mobile touch targets", () => { - test.use({ viewport: MOBILE_VIEWPORT }); + test.use({ viewport: MOBILE_VIEWPORT, hasTouch: true }); test("keeps Home, Configuration, and History controls at least 44px", async ({ page, diff --git a/frontend/src/components/Chat/MessageList.styles.ts b/frontend/src/components/Chat/MessageList.styles.ts index 320c3f903c..29603f7514 100644 --- a/frontend/src/components/Chat/MessageList.styles.ts +++ b/frontend/src/components/Chat/MessageList.styles.ts @@ -1,5 +1,5 @@ import { makeStyles, tokens } from '@fluentui/react-components' -import { MOBILE_BREAKPOINT, mobileTouchTarget } from '../../styles/touchTargets' +import { NARROW_VIEWPORT_QUERY, mobileTouchTarget } from '../../styles/touchTargets' export const useMessageListStyles = makeStyles({ root: { @@ -197,7 +197,7 @@ export const useMessageListStyles = makeStyles({ justifyContent: 'flex-end', gap: tokens.spacingHorizontalXXS, marginTop: tokens.spacingVerticalS, - [MOBILE_BREAKPOINT]: { + [NARROW_VIEWPORT_QUERY]: { flexWrap: 'wrap', }, }, diff --git a/frontend/src/components/Config/TargetConfig.styles.ts b/frontend/src/components/Config/TargetConfig.styles.ts index b9ff4abd09..f44554cd80 100644 --- a/frontend/src/components/Config/TargetConfig.styles.ts +++ b/frontend/src/components/Config/TargetConfig.styles.ts @@ -1,6 +1,7 @@ import { makeStyles, tokens } from '@fluentui/react-components' import { - MOBILE_BREAKPOINT, + NARROW_VIEWPORT_QUERY, + TOUCH_INPUT_QUERY, MINIMUM_TOUCH_TARGET_SIZE, mobileTouchTarget, } from '../../styles/touchTargets' @@ -50,8 +51,10 @@ export const useTargetConfigStyles = makeStyles({ }, }, headerAction: { - [MOBILE_BREAKPOINT]: { + [NARROW_VIEWPORT_QUERY]: { flex: '1 1 8rem', + }, + [TOUCH_INPUT_QUERY]: { minHeight: MINIMUM_TOUCH_TARGET_SIZE, }, }, diff --git a/frontend/src/components/Config/TargetTable.styles.ts b/frontend/src/components/Config/TargetTable.styles.ts index adcbd060bb..9e7af2032f 100644 --- a/frontend/src/components/Config/TargetTable.styles.ts +++ b/frontend/src/components/Config/TargetTable.styles.ts @@ -1,6 +1,6 @@ import { makeStyles, tokens } from '@fluentui/react-components' import { - MOBILE_BREAKPOINT, + TOUCH_INPUT_QUERY, MINIMUM_TOUCH_TARGET_SIZE, mobileTouchTarget, mobileTouchTargetHeight, @@ -94,7 +94,7 @@ export const useTargetTableStyles = makeStyles({ maxWidth: '20rem', ...mobileTouchTargetHeight, '& > select': { - [MOBILE_BREAKPOINT]: { + [TOUCH_INPUT_QUERY]: { minHeight: MINIMUM_TOUCH_TARGET_SIZE, }, }, diff --git a/frontend/src/components/History/AttackHistory.styles.ts b/frontend/src/components/History/AttackHistory.styles.ts index fd4828e79e..f01526bca8 100644 --- a/frontend/src/components/History/AttackHistory.styles.ts +++ b/frontend/src/components/History/AttackHistory.styles.ts @@ -1,6 +1,6 @@ import { makeStyles, tokens } from '@fluentui/react-components' import { - MOBILE_BREAKPOINT, + TOUCH_INPUT_QUERY, MINIMUM_TOUCH_TARGET_SIZE, mobileTouchTarget, mobileTouchTargetHeight, @@ -35,12 +35,12 @@ export const useAttackHistoryStyles = makeStyles({ minWidth: '160px', ...mobileTouchTargetHeight, '& > input': { - [MOBILE_BREAKPOINT]: { + [TOUCH_INPUT_QUERY]: { minHeight: MINIMUM_TOUCH_TARGET_SIZE, }, }, '& > [role="button"]': { - [MOBILE_BREAKPOINT]: { + [TOUCH_INPUT_QUERY]: { display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/frontend/src/styles/touchTargets.ts b/frontend/src/styles/touchTargets.ts index 6631cc5284..2942fe64c1 100644 --- a/frontend/src/styles/touchTargets.ts +++ b/frontend/src/styles/touchTargets.ts @@ -1,16 +1,21 @@ -export const MOBILE_BREAKPOINT = '@media (max-width: 600px)' -export const COARSE_POINTER = '@media (pointer: coarse)' +// Screen width only - for layout/reflow decisions (wrapping, stacking, +// hiding columns). +export const NARROW_VIEWPORT_QUERY = '@media (max-width: 600px)' + +// Input mechanism only - for sizing hit areas for a finger vs. a cursor. +export const TOUCH_INPUT_QUERY = '@media (pointer: coarse)' + export const MINIMUM_TOUCH_TARGET_SIZE = '2.75rem' export const mobileTouchTarget = { - [COARSE_POINTER]: { + [TOUCH_INPUT_QUERY]: { minWidth: MINIMUM_TOUCH_TARGET_SIZE, minHeight: MINIMUM_TOUCH_TARGET_SIZE, }, } export const mobileTouchTargetHeight = { - [COARSE_POINTER]: { + [TOUCH_INPUT_QUERY]: { minHeight: MINIMUM_TOUCH_TARGET_SIZE, }, } From bb304b02eb28e9a4ae228992a41f88b09875d96d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:27:07 -0700 Subject: [PATCH 4/5] Fix mobile accessibility touch context Run the mobile accessibility E2E checks with touch input emulation so pointer: coarse touch-target styles are exercised while the desktop tour check stays in its default context. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a26c899-a856-4e2e-bf8f-24de153c0154 --- frontend/e2e/accessibility.spec.ts | 140 ++++++++++++++++------------- 1 file changed, 78 insertions(+), 62 deletions(-) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 4dad46118a..adc89c464a 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -1,6 +1,11 @@ import { test, expect, type Locator, type Page } from "@playwright/test"; import { makeTarget } from "./_targets"; +const MOBILE_VIEWPORT = { width: 390, height: 844 }; +const DESKTOP_VIEWPORT = { width: 1280, height: 800 }; + +type TourViewportName = "mobile" | "desktop"; + async function expectMinimumTouchTarget(locator: Locator, minimum = 44): Promise { await expect(locator).toBeVisible(); @@ -59,6 +64,37 @@ async function expectTourContained(page: Page, dialog: Locator, checkTouchTarget } } +async function expectTourContainedAndActionable( + page: Page, + viewportName: TourViewportName +): Promise { + await page.getByRole("button", { name: "Take a tour" }).click(); + + const dialog = page.getByRole("alertdialog"); + + for (let step = 0; step < 5; step += 1) { + await expect(dialog).toContainText(`${step + 1} of 5`); + await expectTourContained(page, dialog, viewportName === "mobile"); + + if (viewportName === "desktop" && step === 0) { + const targetBox = await page.locator('[data-tour="sidebar-nav"]').boundingBox(); + const dialogBox = await dialog.boundingBox(); + + expect(targetBox).not.toBeNull(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox!.x).toBeGreaterThanOrEqual(targetBox!.x + targetBox!.width); + } + + if (step < 4) { + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + } + } + + await dialog.getByRole("button", { name: "Anchors Away!", exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/\/$/); +} + test.describe("Accessibility", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); @@ -270,73 +306,53 @@ test.describe("Accessibility", () => { } }); - test("mobile audit controls provide 44px touch targets", async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); - - await expectMinimumTouchTarget(page.getByRole("button", { name: "Labels" })); - await expectMinimumTouchTarget(page.getByRole("button", { name: "Configure a target" })); - await expectMinimumTouchTarget(page.getByRole("button", { name: "Take a tour" })); - - await page.route(/\/api\/targets/, async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - items: [ - makeTarget({ - target_registry_name: "mobile-touch-target", - target_type: "OpenAIChatTarget", - endpoint: "https://test.com", - model_name: "gpt-4o", - }), - ], - pagination: { limit: 200, has_more: false, next_cursor: null, prev_cursor: null }, - }), + test.describe("mobile touch context", () => { + test.use({ hasTouch: true }); + + test("mobile audit controls provide 44px touch targets", async ({ page }) => { + await page.setViewportSize(MOBILE_VIEWPORT); + + await expectMinimumTouchTarget(page.getByRole("button", { name: "Labels" })); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Configure a target" })); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Take a tour" })); + + await page.route(/\/api\/targets/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + makeTarget({ + target_registry_name: "mobile-touch-target", + target_type: "OpenAIChatTarget", + endpoint: "https://test.com", + model_name: "gpt-4o", + }), + ], + pagination: { limit: 200, has_more: false, next_cursor: null, prev_cursor: null }, + }), + }); }); + + await page.getByRole("button", { name: "Configuration" }).click(); + await expect( + page.getByRole("heading", { level: 1, name: "Target Configuration" }) + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh" })).toBeEnabled(); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Refresh" })); + await expectMinimumTouchTarget(page.getByRole("button", { name: "New Target" })); }); - await page.getByRole("button", { name: "Configuration" }).click(); - await expect( - page.getByRole("heading", { level: 1, name: "Target Configuration" }) - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Refresh" })).toBeEnabled(); - await expectMinimumTouchTarget(page.getByRole("button", { name: "Refresh" })); - await expectMinimumTouchTarget(page.getByRole("button", { name: "New Target" })); + test("tour remains contained and actionable on mobile", async ({ page }) => { + await page.setViewportSize(MOBILE_VIEWPORT); + await expectTourContainedAndActionable(page, "mobile"); + }); }); - for (const viewport of [ - { name: "mobile", width: 390, height: 844 }, - { name: "desktop", width: 1280, height: 800 }, - ]) { - test(`tour remains contained and actionable on ${viewport.name}`, async ({ page }) => { - await page.setViewportSize({ width: viewport.width, height: viewport.height }); - await page.getByRole("button", { name: "Take a tour" }).click(); - - const dialog = page.getByRole("alertdialog"); - - for (let step = 0; step < 5; step += 1) { - await expect(dialog).toContainText(`${step + 1} of 5`); - await expectTourContained(page, dialog, viewport.name === "mobile"); - - if (viewport.name === "desktop" && step === 0) { - const targetBox = await page.locator('[data-tour="sidebar-nav"]').boundingBox(); - const dialogBox = await dialog.boundingBox(); - - expect(targetBox).not.toBeNull(); - expect(dialogBox).not.toBeNull(); - expect(dialogBox!.x).toBeGreaterThanOrEqual(targetBox!.x + targetBox!.width); - } - - if (step < 4) { - await dialog.getByRole("button", { name: "Next", exact: true }).click(); - } - } - - await dialog.getByRole("button", { name: "Anchors Away!", exact: true }).click(); - await expect(dialog).toBeHidden(); - await expect(page).toHaveURL(/\/$/); - }); - } + test("tour remains contained and actionable on desktop", async ({ page }) => { + await page.setViewportSize(DESKTOP_VIEWPORT); + await expectTourContainedAndActionable(page, "desktop"); + }); }); test.describe("Visual Consistency", () => { From c00378e5f358199cfe53e805ff7904ac2896bf58 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:11:56 -0700 Subject: [PATCH 5/5] Round E2E touch-target measurements to whole pixels The rem-based sizing (2.75rem) resolves to 43.999969px in the layout engine, so getBoundingClientRect can report just under 44 and the >= 44 assertion intermittently fails. With --fail-on-flaky-tests this fails the whole job. Rounding the measured width/height to whole pixels reflects the element's true 44px CSS size while still catching any real (sub-44px) regression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a26c899-a856-4e2e-bf8f-24de153c0154 --- frontend/e2e/accessibility.spec.ts | 4 ++-- frontend/e2e/touch-targets.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index adc89c464a..b4e437eab4 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -12,8 +12,8 @@ async function expectMinimumTouchTarget(locator: Locator, minimum = 44): Promise const box = await locator.boundingBox(); expect(box).not.toBeNull(); - expect(box!.width).toBeGreaterThanOrEqual(minimum); - expect(box!.height).toBeGreaterThanOrEqual(minimum); + expect(Math.round(box!.width)).toBeGreaterThanOrEqual(minimum); + expect(Math.round(box!.height)).toBeGreaterThanOrEqual(minimum); } async function expectTourContained(page: Page, dialog: Locator, checkTouchTargets: boolean): Promise { diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index 0543cc8a63..5c3dd881fb 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -278,8 +278,8 @@ async function expectMinimumTouchTarget(locator: Locator): Promise { if (!box) { throw new Error("Expected a visible touch target"); } - expect(box.width).toBeGreaterThanOrEqual(MINIMUM_TOUCH_TARGET_SIZE); - expect(box.height).toBeGreaterThanOrEqual(MINIMUM_TOUCH_TARGET_SIZE); + expect(Math.round(box.width)).toBeGreaterThanOrEqual(MINIMUM_TOUCH_TARGET_SIZE); + expect(Math.round(box.height)).toBeGreaterThanOrEqual(MINIMUM_TOUCH_TARGET_SIZE); } async function expectMinimumTouchTargets(locator: Locator): Promise {