From 0c23121c01f4b4d4975a28fd5a0b703a6b5a6d25 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:59:10 -0700 Subject: [PATCH] Fix mobile tour and touch targets Keep onboarding tour steps within the viewport and standardize mobile-only 44px touch targets across audited frontend surfaces. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac28dca5-03cd-4d4e-809f-408112dc84db --- frontend/e2e/accessibility.spec.ts | 289 ++++++++++++++++-- .../src/components/Chat/ChatInputArea.tsx | 20 +- frontend/src/components/Chat/ChatWindow.tsx | 8 +- .../src/components/Chat/SystemPromptSetup.tsx | 6 +- .../components/Config/TargetConfig.styles.ts | 1 - .../src/components/Config/TargetConfig.tsx | 10 +- .../src/components/Config/TargetTable.tsx | 11 +- .../src/components/History/AttackHistory.tsx | 7 + .../src/components/History/AttackTable.tsx | 9 +- .../components/History/HistoryFiltersBar.tsx | 16 +- .../components/History/HistoryPagination.tsx | 5 + frontend/src/components/Home/Home.styles.ts | 5 - frontend/src/components/Home/Home.tsx | 11 +- .../src/components/Tour/TourTooltip.styles.ts | 12 - .../src/components/Tour/TourTooltip.test.tsx | 27 ++ frontend/src/components/Tour/TourTooltip.tsx | 16 +- frontend/src/hooks/useTour.ts | 3 + .../src/styles/mobileTouchTargetStyles.ts | 29 ++ 18 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 frontend/src/styles/mobileTouchTargetStyles.ts diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 4dad46118a..437cac8134 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -11,6 +11,24 @@ async function expectMinimumTouchTarget(locator: Locator, minimum = 44): Promise expect(box!.height).toBeGreaterThanOrEqual(minimum); } +async function expectCompactDesktopTarget(locator: Locator, maximumHeight = 40): Promise { + await expect(locator).toBeVisible(); + + const box = await locator.boundingBox(); + + expect(box).not.toBeNull(); + expect(box!.height).toBeLessThanOrEqual(maximumHeight); +} + +async function expectNoDocumentOverflow(page: Page): Promise { + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1); +} + async function expectTourContained(page: Page, dialog: Locator, checkTouchTargets: boolean): Promise { await expect(dialog).toBeVisible(); await page.evaluate( @@ -55,10 +73,164 @@ async function expectTourContained(page: Page, dialog: Locator, checkTouchTarget if (checkTouchTargets) { await expectMinimumTouchTarget(action); + } else { + await expectCompactDesktopTarget(action); } } } +const MOBILE_AUDIT_ATTACK = { + attack_result_id: "mobile-audit-attack", + conversation_id: "mobile-audit-conversation", + attack_type: "SingleTurnAttack", + target: { target_type: "OpenAIChatTarget", model_name: "gpt-4o" }, + converters: [], + outcome: "success", + last_message_preview: "Mobile audit response", + message_count: 2, + related_conversation_ids: [], + labels: { operator: "mobile", operation: "audit" }, + created_at: "2026-07-28T12:00:00.000Z", + updated_at: "2026-07-28T12:00:00.000Z", +}; + +const MOBILE_AUDIT_TARGETS = [ + makeTarget({ + target_registry_name: "mobile-chat-target", + target_type: "OpenAIChatTarget", + endpoint: "https://test.com/chat", + model_name: "gpt-4o", + capabilities: { supports_multi_turn: true, supports_system_prompt: true }, + }), + makeTarget({ + target_registry_name: "mobile-image-target", + target_type: "OpenAIImageTarget", + endpoint: "https://test.com/image", + model_name: "image-model", + }), + makeTarget({ + target_registry_name: "mobile-round-robin-target", + target_type: "RoundRobinTarget", + target_specific_params: { weights: [1] }, + inner_targets: [ + { + target_registry_name: "mobile-inner-target", + target_type: "OpenAIChatTarget", + endpoint: "https://test.com/inner", + model_name: "inner-model", + }, + ], + }), +]; + +interface ResponsiveAuditRouteOptions { + empty?: boolean; + failAttacks?: boolean; +} + +async function routeResponsiveAuditData( + page: Page, + { empty = false, failAttacks = false }: ResponsiveAuditRouteOptions = {} +): Promise { + await page.route(/\/api\/targets\/catalog(?:\?.*)?$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { target_type: "OpenAIChatTarget", parameters: [], supported_auth_modes: ["api_key"] }, + { target_type: "OpenAIImageTarget", parameters: [], supported_auth_modes: ["api_key"] }, + { target_type: "RoundRobinTarget", parameters: [], supported_auth_modes: ["api_key"] }, + ], + }), + }); + }); + await page.route(/\/api\/targets(?:\?.*)?$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: empty ? [] : MOBILE_AUDIT_TARGETS, + pagination: { limit: 200, has_more: false, next_cursor: null, prev_cursor: null }, + }), + }); + }); + await page.route(/\/api\/attacks\/attack-options/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ attack_types: ["SingleTurnAttack"] }), + }); + }); + await page.route(/\/api\/attacks\/converter-options/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ converter_types: ["Base64Converter"] }), + }); + }); + await page.route(/\/api\/labels/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + source: "attacks", + labels: { operator: ["mobile"], operation: ["audit"] }, + }), + }); + }); + await page.route(/\/api\/attacks\/mobile-audit-attack\/messages/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + messages: { + messages: [], + }, + }), + }); + }); + await page.route(/\/api\/attacks\/mobile-audit-attack$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOBILE_AUDIT_ATTACK), + }); + }); + await page.route(/\/api\/attacks(?:\?.*)?$/, async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + attack_result_id: "mobile-audit-attack", + conversation_id: "mobile-audit-conversation", + }), + }); + return; + } + + if (failAttacks) { + await route.fulfill({ status: 500, body: "History unavailable" }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: empty ? [] : [MOBILE_AUDIT_ATTACK], + pagination: { + limit: 25, + has_more: !empty, + next_cursor: empty ? null : "next", + prev_cursor: null, + }, + }), + }); + }); +} + test.describe("Accessibility", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); @@ -272,28 +444,16 @@ test.describe("Accessibility", () => { test("mobile audit controls provide 44px touch targets", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); + await routeResponsiveAuditData(page); + await page.reload(); 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 }, - }), - }); - }); + const recentAttackRows = page.locator('[data-testid^="home-open-attack-"]'); + await expect(recentAttackRows).toHaveCount(1); + await expectMinimumTouchTarget(recentAttackRows.first()); + await expectNoDocumentOverflow(page); await page.getByRole("button", { name: "Configuration" }).click(); await expect( @@ -302,6 +462,99 @@ test.describe("Accessibility", () => { await expect(page.getByRole("button", { name: "Refresh" })).toBeEnabled(); await expectMinimumTouchTarget(page.getByRole("button", { name: "Refresh" })); await expectMinimumTouchTarget(page.getByRole("button", { name: "New Target" })); + await expectMinimumTouchTarget(page.getByRole("combobox", { name: "Filter by type:" })); + + const setActiveButtons = page.getByRole("button", { name: "Set Active" }); + await expect(setActiveButtons).toHaveCount(3); + for (let index = 0; index < await setActiveButtons.count(); index += 1) { + await expectMinimumTouchTarget(setActiveButtons.nth(index)); + } + await expectMinimumTouchTarget(page.getByRole("button", { name: "Expand inner targets" })); + await setActiveButtons.first().click(); + await expectNoDocumentOverflow(page); + + await page.getByRole("button", { name: "Attack History" }).click(); + await expect(page.getByTestId("attacks-table")).toBeVisible(); + await expectMinimumTouchTarget(page.getByTestId("refresh-btn")); + for (const testId of [ + "attack-type-filter", + "outcome-filter", + "converter-filter", + "operator-filter", + "operation-filter", + "label-filter", + ]) { + await expectMinimumTouchTarget(page.getByTestId(testId)); + } + await expectMinimumTouchTarget(page.getByTestId("open-attack-mobile-audit-attack")); + await expectMinimumTouchTarget(page.getByTestId("attack-row-mobile-audit-attack")); + await expectMinimumTouchTarget(page.getByTestId("next-page-btn")); + await expectNoDocumentOverflow(page); + + await page.getByRole("button", { name: "Chat" }).click(); + await expectMinimumTouchTarget(page.getByTestId("toggle-system-prompt-btn")); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Attach files" })); + await expectMinimumTouchTarget(page.getByTestId("toggle-converter-panel-btn")); + await page.getByPlaceholder("Type prompt here").fill("Create a populated mobile chat"); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Send message" })); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(page.getByTestId("toggle-panel-btn")).toBeEnabled(); + await expectMinimumTouchTarget(page.getByTestId("toggle-panel-btn")); + await expectMinimumTouchTarget(page.getByTestId("new-attack-btn")); + await expectNoDocumentOverflow(page); + }); + + test("empty mobile audit actions provide 44px touch targets", async ({ page }) => { + await page.setViewportSize({ width: 600, height: 844 }); + await routeResponsiveAuditData(page, { empty: true }); + await page.reload(); + + await expectMinimumTouchTarget(page.getByTestId("home-start-attack-btn")); + await expectNoDocumentOverflow(page); + + await page.getByRole("button", { name: "Configuration" }).click(); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Create First Target" })); + await expectNoDocumentOverflow(page); + + await page.getByRole("button", { name: "Chat" }).click(); + await expectMinimumTouchTarget(page.getByTestId("configure-target-input-btn")); + await expectNoDocumentOverflow(page); + }); + + test("mobile history retry action provides a 44px touch target", async ({ page }) => { + await page.setViewportSize({ width: 600, height: 844 }); + await routeResponsiveAuditData(page, { failAttacks: true }); + await page.reload(); + + await page.getByRole("button", { name: "Attack History" }).click(); + await expectMinimumTouchTarget(page.getByTestId("retry-btn")); + await expectNoDocumentOverflow(page); + }); + + test("desktop audit controls retain compact density", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await routeResponsiveAuditData(page); + await page.reload(); + + await expectCompactDesktopTarget(page.getByTestId("home-open-attack-mobile-audit-attack")); + + await page.getByRole("button", { name: "Configuration" }).click(); + await expectCompactDesktopTarget(page.getByRole("combobox", { name: "Filter by type:" })); + await expectCompactDesktopTarget(page.getByRole("button", { name: "Set Active" }).first()); + await expectCompactDesktopTarget(page.getByRole("button", { name: "Expand inner targets" })); + await page.getByRole("button", { name: "Set Active" }).first().click(); + + await page.getByRole("button", { name: "Attack History" }).click(); + await expect(page.getByTestId("attacks-table")).toBeVisible(); + await expectCompactDesktopTarget(page.getByTestId("refresh-btn")); + await expectCompactDesktopTarget(page.getByTestId("attack-type-filter")); + await expectCompactDesktopTarget(page.getByTestId("open-attack-mobile-audit-attack")); + await expectCompactDesktopTarget(page.getByTestId("next-page-btn")); + + await page.getByRole("button", { name: "Chat" }).click(); + await expectCompactDesktopTarget(page.getByTestId("toggle-system-prompt-btn")); + await expectCompactDesktopTarget(page.getByTestId("toggle-panel-btn")); + await expectCompactDesktopTarget(page.getByTestId("new-attack-btn")); }); for (const viewport of [ diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 898d71a273..dff6f08d89 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -8,6 +8,9 @@ import { mergeClasses, } from '@fluentui/react-components' import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular, OpenRegular } from '@fluentui/react-icons' + +import { useMobileTouchTargetStyles } from '@/styles/mobileTouchTargetStyles' + import { MessageAttachment, TargetInstance } from '../../types' import { useChatInputAreaStyles } from './ChatInputArea.styles' import SystemPromptSetup from './SystemPromptSetup' @@ -33,9 +36,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} @@ -48,6 +52,7 @@ function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testI icon={buttonIcon} onClick={onButtonClick} data-testid={buttonTestId} + className={buttonClassName} > {buttonText} @@ -340,6 +345,7 @@ interface ChatInputAreaProps { const ChatInputArea = forwardRef(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip, showSystemPrompt = false, supportsSystemPrompt = false, systemPrompt = '', onSystemPromptChange }, ref) { const styles = useChatInputAreaStyles() + const mobileTouchTargets = useMobileTouchTargetStyles() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) const fileInputRef = useRef(null) @@ -504,6 +510,7 @@ const ChatInputArea = forwardRef(functi onButtonClick={onConfigureTarget} testId="no-target-banner" buttonTestId="configure-target-input-btn" + buttonClassName={mobileTouchTargets.control} /> ) : operatorLocked ? ( (functi onButtonClick={onUseAsTemplate} testId="operator-locked-banner" buttonTestId="use-as-template-btn" + buttonClassName={mobileTouchTargets.control} /> ) : crossTargetLocked ? ( (functi onButtonClick={onUseAsTemplate} testId="cross-target-banner" buttonTestId="use-as-template-btn" + buttonClassName={mobileTouchTargets.control} /> ) : singleTurnLimitReached ? ( (functi onButtonClick={onNewConversation} testId="single-turn-banner" buttonTestId="new-conversation-btn" + buttonClassName={mobileTouchTargets.control} /> ) : ( <> @@ -563,7 +573,7 @@ const ChatInputArea = forwardRef(functi
diff --git a/frontend/src/components/Chat/SystemPromptSetup.tsx b/frontend/src/components/Chat/SystemPromptSetup.tsx index d362058bc7..b60c58d285 100644 --- a/frontend/src/components/Chat/SystemPromptSetup.tsx +++ b/frontend/src/components/Chat/SystemPromptSetup.tsx @@ -1,6 +1,9 @@ import { useState } from 'react' import { Button, Caption1, Textarea, mergeClasses } from '@fluentui/react-components' import { ChevronDownRegular, ChevronRightRegular, WarningRegular } from '@fluentui/react-icons' + +import { useMobileTouchTargetStyles } from '@/styles/mobileTouchTargetStyles' + import { useSystemPromptSetupStyles } from './SystemPromptSetup.styles' const SYSTEM_PROMPT_SOFT_LIMIT = 2000 @@ -13,6 +16,7 @@ interface SystemPromptSetupProps { export default function SystemPromptSetup({ value, onChange, disabled = false }: SystemPromptSetupProps) { const styles = useSystemPromptSetupStyles() + const mobileTouchTargets = useMobileTouchTargetStyles() const [expanded, setExpanded] = useState(false) const overLimit = value.length > SYSTEM_PROMPT_SOFT_LIMIT @@ -24,7 +28,7 @@ export default function SystemPromptSetup({ value, onChange, disabled = false }: size="small" icon={expanded ? : } onClick={() => setExpanded(prev => !prev)} - className={styles.header} + className={mergeClasses(styles.header, mobileTouchTargets.control)} data-testid="toggle-system-prompt-btn" aria-expanded={expanded} disabled={disabled} diff --git a/frontend/src/components/Config/TargetConfig.styles.ts b/frontend/src/components/Config/TargetConfig.styles.ts index 9d90bffdc2..b7080a6129 100644 --- a/frontend/src/components/Config/TargetConfig.styles.ts +++ b/frontend/src/components/Config/TargetConfig.styles.ts @@ -47,7 +47,6 @@ export const useTargetConfigStyles = makeStyles({ headerAction: { '@media (max-width: 600px)': { flex: '1 1 8rem', - minHeight: '44px', }, }, emptyState: { diff --git a/frontend/src/components/Config/TargetConfig.tsx b/frontend/src/components/Config/TargetConfig.tsx index feb067511a..fd0a8771cf 100644 --- a/frontend/src/components/Config/TargetConfig.tsx +++ b/frontend/src/components/Config/TargetConfig.tsx @@ -4,9 +4,13 @@ import { Text, Button, Link, + mergeClasses, Spinner, } from '@fluentui/react-components' import { AddRegular, ArrowSyncRegular } from '@fluentui/react-icons' + +import { useMobileTouchTargetStyles } from '@/styles/mobileTouchTargetStyles' + import { targetsApi } from '../../services/api' import { toApiError } from '../../services/errors' import type { TargetInstance } from '../../types' @@ -21,6 +25,7 @@ interface TargetConfigProps { export default function TargetConfig({ activeTarget, onSetActiveTarget }: TargetConfigProps) { const styles = useTargetConfigStyles() + const mobileTouchTargets = useMobileTouchTargetStyles() const [targets, setTargets] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -83,7 +88,7 @@ export default function TargetConfig({ activeTarget, onSetActiveTarget }: Target
diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index 062dc4132d..63fa2e0bcf 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -11,6 +11,7 @@ import { Text, Tooltip, Select, + mergeClasses, } from '@fluentui/react-components' import { CheckmarkRegular, @@ -29,8 +30,12 @@ import { ChevronRightRegular, ChevronDownRegular, } from '@fluentui/react-icons' + +import { useMobileTouchTargetStyles } from '@/styles/mobileTouchTargetStyles' + import type { TargetInstance } from '../../types' import { targetEndpoint, targetModelName, targetType, targetUnderlyingModelName } from '../../utils/targetIdentity' + import { useTargetTableStyles } from './TargetTable.styles' interface TargetTableProps { @@ -242,6 +247,7 @@ function InnerTargetRows({ parentKey, innerTargets, weights }: { export default function TargetTable({ targets, activeTarget, onSetActiveTarget }: TargetTableProps) { const styles = useTargetTableStyles() + const mobileTouchTargets = useMobileTouchTargetStyles() const typeFilterId = useId() const [typeFilter, setTypeFilter] = useState('') // Tracks which RoundRobinTarget rows are expanded to show inner targets. @@ -295,6 +301,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } icon={expandedRows.has(activeTarget.target_registry_name) ? : } onClick={() => toggleExpanded(activeTarget.target_registry_name)} aria-label={expandedRows.has(activeTarget.target_registry_name) ? 'Collapse inner targets' : 'Expand inner targets'} + className={mobileTouchTargets.control} /> )} {targetType(activeTarget)} @@ -340,7 +347,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget }