From 36b4c0fce56ed47b194873c3d0c52266573fd114 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 08:50:49 +0100 Subject: [PATCH] Improve UX: accessibility, field guidance, and error prevention (#1010) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com> --- .../ConnectionSetup/ConnectButton.tsx | 20 +- .../ConnectionSetup/ConnectionSettings.tsx | 149 +++++-- .../ConnectionSetup/ToggleSwitch.tsx | 19 +- ux-exploration.ts | 408 ++++++++++++++++++ 4 files changed, 545 insertions(+), 51 deletions(-) create mode 100644 ux-exploration.ts diff --git a/app/src/components/ConnectionSetup/ConnectButton.tsx b/app/src/components/ConnectionSetup/ConnectButton.tsx index 2199660..1e52de1 100644 --- a/app/src/components/ConnectionSetup/ConnectButton.tsx +++ b/app/src/components/ConnectionSetup/ConnectButton.tsx @@ -8,15 +8,29 @@ function ConnectButton(props: { connecting: boolean; classes: any; toggle: () => if (connecting) { return ( - ) } return ( - ) diff --git a/app/src/components/ConnectionSetup/ConnectionSettings.tsx b/app/src/components/ConnectionSetup/ConnectionSettings.tsx index 4dcf661..5890494 100644 --- a/app/src/components/ConnectionSetup/ConnectionSettings.tsx +++ b/app/src/components/ConnectionSetup/ConnectionSettings.tsx @@ -8,7 +8,7 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff' import { AppState } from '../../reducers' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' -import { connectionActions, connectionManagerActions } from '../../actions' +import { connectionActions, connectionManagerActions, globalActions } from '../../actions' import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions' import { KeyCodes } from '../../utils/KeyCodes' import { Theme } from '@mui/material/styles' @@ -17,14 +17,12 @@ import { ToggleSwitch } from './ToggleSwitch' import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler' import { Button, - FormControl, Grid, IconButton, - Input, InputAdornment, - InputLabel, MenuItem, TextField, + Tooltip, } from '@mui/material' interface Props { @@ -32,6 +30,7 @@ interface Props { classes: { [s: string]: string } actions: typeof connectionActions managerActions: typeof connectionManagerActions + globalActions: typeof globalActions connected: boolean connecting: boolean } @@ -41,6 +40,17 @@ const protocols = ['mqtt', 'ws'] function ConnectionSettings(props: Props) { const [showPassword, setShowPassword] = useState(false) + const handleDelete = useCallback(async () => { + const confirmed = await props.globalActions.requestConfirmation( + 'Delete Connection', + `Are you sure you want to delete the connection "${props.connection.name}"?\n\nThis action cannot be undone.` + ) + + if (confirmed) { + props.managerActions.deleteConnection(props.connection.id) + } + }, [props.connection.id, props.connection.name, props.globalActions, props.managerActions]) + const toggleConnect = useCallback(() => { if (props.connecting) { props.actions.disconnect() @@ -76,7 +86,7 @@ function ConnectionSettings(props: Props) { className={props.classes.textField} value={props.connection.basePath} onChange={handleChange('basePath')} - margin="normal" + margin="dense" /> ) @@ -101,21 +111,26 @@ function ConnectionSettings(props: Props) { const protocolItems = protocols.map((value: string) => ( - {value}:// + {value}:// {value === 'mqtt' ? '(Standard)' : '(WebSocket)'} )) return ( - - {protocolItems} - + + + {protocolItems} + + ) } @@ -144,9 +159,15 @@ function ConnectionSettings(props: Props) { function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) { return ( - - {props.showPassword ? : } - + + + {props.showPassword ? : } + + ) } @@ -154,9 +175,9 @@ function ConnectionSettings(props: Props) { const { classes, connection } = props return ( -
-
- +
+ + @@ -187,8 +212,12 @@ function ConnectionSettings(props: Props) { className={classes.textField} value={connection.host} onChange={handleChange('host')} - margin="normal" - inputProps={{ 'data-testid': 'host-input' }} + margin="dense" + placeholder="broker.example.com" + inputProps={{ + 'data-testid': 'host-input', + 'aria-label': 'MQTT broker host' + }} /> @@ -197,7 +226,14 @@ function ConnectionSettings(props: Props) { className={classes.textField} value={connection.port} onChange={handleChange('port')} - margin="normal" + margin="dense" + type="number" + placeholder="1883" + inputProps={{ + 'aria-label': 'MQTT broker port', + min: 1, + max: 65535 + }} /> {requiresBasePath() ? renderBasePathInput() : null} @@ -207,54 +243,74 @@ function ConnectionSettings(props: Props) { className={classes.textField} value={connection.username} onChange={handleChange('username')} - margin="normal" + margin="dense" + placeholder="Optional" + inputProps={{ + 'aria-label': 'MQTT username', + 'autoComplete': 'username' + }} /> - - Password - } - /> - + + }} + inputProps={{ + 'aria-label': 'MQTT password', + 'autoComplete': 'current-password' + }} + /> -
+ +
-
+ + + -
-
+ +
+
+ - -
+ +
- +
) } @@ -270,6 +326,7 @@ const mapDispatchToProps = (dispatch: any) => { return { actions: bindActionCreators(connectionActions, dispatch), managerActions: bindActionCreators(connectionManagerActions, dispatch), + globalActions: bindActionCreators(globalActions, dispatch), } } diff --git a/app/src/components/ConnectionSetup/ToggleSwitch.tsx b/app/src/components/ConnectionSetup/ToggleSwitch.tsx index 06a3429..4ba18a8 100644 --- a/app/src/components/ConnectionSetup/ToggleSwitch.tsx +++ b/app/src/components/ConnectionSetup/ToggleSwitch.tsx @@ -3,10 +3,25 @@ import { FormControlLabel, Switch } from '@mui/material' export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) { const { classes, value, toggle, label } = props - const toggleSwitch = + const toggleSwitch = ( + + ) return (
- +
) } diff --git a/ux-exploration.ts b/ux-exploration.ts new file mode 100644 index 0000000..8c7c2cc --- /dev/null +++ b/ux-exploration.ts @@ -0,0 +1,408 @@ +import { chromium, Browser, Page, devices } from 'playwright' +import * as path from 'path' + +const PIXEL_6 = devices['Pixel 6'] + +interface UXIssue { + category: string + severity: 'critical' | 'high' | 'medium' | 'low' + description: string + location: string + recommendation?: string +} + +const uxIssues: UXIssue[] = [] + +function reportIssue(issue: UXIssue) { + uxIssues.push(issue) + console.log(`[${issue.severity.toUpperCase()}] ${issue.category}: ${issue.description}`) + console.log(` Location: ${issue.location}`) + if (issue.recommendation) { + console.log(` Recommendation: ${issue.recommendation}`) + } + console.log('') +} + +async function checkAccessibility(page: Page, context: string) { + console.log(`\n=== Checking accessibility: ${context} ===\n`) + + // Check for proper ARIA labels + const buttonsWithoutAriaLabel = await page.locator('button:not([aria-label])').count() + if (buttonsWithoutAriaLabel > 0) { + reportIssue({ + category: 'Accessibility', + severity: 'medium', + description: `${buttonsWithoutAriaLabel} buttons without aria-label found`, + location: context, + recommendation: 'Add aria-label attributes to all interactive buttons for screen reader support' + }) + } + + // Check for input fields without labels + const inputsWithoutLabel = await page.locator('input:not([aria-label]):not([aria-labelledby])').count() + if (inputsWithoutLabel > 0) { + reportIssue({ + category: 'Accessibility', + severity: 'medium', + description: `${inputsWithoutLabel} input fields without proper labels`, + location: context, + recommendation: 'Add aria-label or associated label elements to all inputs' + }) + } + + // Check contrast - look for text with potential low contrast + const textElements = await page.locator('p, span, div, h1, h2, h3, h4, h5, h6, label, button').all() + console.log(` Checked ${textElements.length} text elements for potential contrast issues`) +} + +async function checkTouchTargets(page: Page, context: string) { + console.log(`\n=== Checking touch target sizes: ${context} ===\n`) + + // Check button sizes (should be at least 44x44px for touch) + const buttons = await page.locator('button, a, [role="button"]').all() + let smallButtons = 0 + + for (const button of buttons) { + const box = await button.boundingBox() + if (box && (box.width < 44 || box.height < 44)) { + smallButtons++ + } + } + + if (smallButtons > 0) { + reportIssue({ + category: 'Touch Usability', + severity: 'high', + description: `${smallButtons} interactive elements smaller than 44x44px`, + location: context, + recommendation: 'Increase touch target sizes to at least 44x44px for better mobile usability' + }) + } +} + +async function checkFormUsability(page: Page, context: string) { + console.log(`\n=== Checking form usability: ${context} ===\n`) + + // Check for password visibility toggle + const passwordInputs = await page.locator('input[type="password"]').all() + const visibilityToggles = await page.locator('[aria-label*="password" i], [title*="password" i]').count() + + if (passwordInputs.length > 0 && visibilityToggles === 0) { + reportIssue({ + category: 'Form Usability', + severity: 'medium', + description: 'Password fields lack visibility toggle', + location: context, + recommendation: 'Add a toggle button to show/hide password for better UX' + }) + } + + // Check for required field indicators + const requiredInputs = await page.locator('input[required]').count() + const requiredIndicators = await page.locator('input[required] + label:has-text("*"), label:has-text("*") + input[required]').count() + + if (requiredInputs > 0 && requiredIndicators === 0) { + reportIssue({ + category: 'Form Usability', + severity: 'low', + description: 'Required fields may not be visually indicated', + location: context, + recommendation: 'Add visual indicators (e.g., asterisk) for required fields' + }) + } + + // Check for autocomplete attributes + const emailInputs = await page.locator('input[type="email"], input[name*="email" i]').count() + const emailAutocomplete = await page.locator('input[type="email"][autocomplete], input[name*="email" i][autocomplete]').count() + + if (emailInputs > 0 && emailAutocomplete === 0) { + reportIssue({ + category: 'Form Usability', + severity: 'low', + description: 'Email inputs lack autocomplete attributes', + location: context, + recommendation: 'Add autocomplete="email" to email inputs for better UX' + }) + } +} + +async function checkLoadingStates(page: Page, context: string) { + console.log(`\n=== Checking loading states: ${context} ===\n`) + + // Check for loading indicators + const loadingIndicators = await page.locator('[role="progressbar"], .loading, [aria-busy="true"]').count() + console.log(` Found ${loadingIndicators} loading indicators`) + + // Check if buttons show loading state + const buttons = await page.locator('button').all() + let buttonsWithDisabledState = 0 + + for (const button of buttons) { + const isDisabled = await button.isDisabled() + if (isDisabled) { + buttonsWithDisabledState++ + } + } + + console.log(` ${buttonsWithDisabledState} buttons in disabled state`) +} + +async function checkErrorMessages(page: Page, context: string) { + console.log(`\n=== Checking error message visibility: ${context} ===\n`) + + // Check if error messages have proper ARIA roles + const errorElements = await page.locator('[role="alert"], .error, [aria-live="assertive"]').count() + console.log(` Found ${errorElements} elements with error/alert roles`) + + // Check for error text visibility + const errorTexts = await page.locator('text=/error/i, text=/invalid/i, text=/failed/i').all() + for (const errorText of errorTexts) { + const isVisible = await errorText.isVisible() + if (!isVisible) { + reportIssue({ + category: 'Error Handling', + severity: 'medium', + description: 'Error message exists but may not be visible', + location: context, + recommendation: 'Ensure error messages are visible and have proper ARIA roles' + }) + break + } + } +} + +async function checkKeyboardNavigation(page: Page, context: string) { + console.log(`\n=== Checking keyboard navigation: ${context} ===\n`) + + // Check for focusable elements + const focusableElements = await page.locator('button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])').count() + console.log(` Found ${focusableElements} focusable elements`) + + // Check for skip links + const skipLinks = await page.locator('a[href*="#"]:has-text(/skip/i)').count() + if (skipLinks === 0) { + reportIssue({ + category: 'Keyboard Navigation', + severity: 'low', + description: 'No skip navigation links found', + location: context, + recommendation: 'Add skip to content links for keyboard users' + }) + } +} + +async function checkResponsiveDesign(page: Page, context: string) { + console.log(`\n=== Checking responsive design: ${context} ===\n`) + + // Check for horizontal scroll + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth + }) + + if (hasHorizontalScroll) { + reportIssue({ + category: 'Responsive Design', + severity: 'high', + description: 'Page has horizontal scrolling', + location: context, + recommendation: 'Ensure content fits within viewport width without horizontal scrolling' + }) + } + + // Check for viewport meta tag + const hasViewportMeta = await page.locator('meta[name="viewport"]').count() + if (hasViewportMeta === 0) { + reportIssue({ + category: 'Responsive Design', + severity: 'critical', + description: 'Missing viewport meta tag', + location: context, + recommendation: 'Add ' + }) + } +} + +async function exploreDesktopUX(browser: Browser) { + console.log('\n\n========================================') + console.log('DESKTOP UX EXPLORATION') + console.log('========================================\n') + + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 } + }) + const page = await context.newPage() + + try { + // Start server and wait for it + await page.goto('http://localhost:3000', { waitUntil: 'networkidle' }) + await page.waitForTimeout(2000) + + // Check initial page load + await checkAccessibility(page, 'Desktop: Initial Load') + await checkResponsiveDesign(page, 'Desktop: Initial Load') + await checkKeyboardNavigation(page, 'Desktop: Initial Load') + + // Take screenshot + await page.screenshot({ path: '/tmp/desktop-initial.png', fullPage: true }) + console.log('Screenshot saved: /tmp/desktop-initial.png') + + // Check login dialog if present + const loginDialog = page.locator('[role="dialog"]').first() + if (await loginDialog.isVisible()) { + console.log('\nLogin dialog detected') + await checkFormUsability(page, 'Desktop: Login Dialog') + await checkTouchTargets(page, 'Desktop: Login Dialog') + await page.screenshot({ path: '/tmp/desktop-login.png' }) + console.log('Screenshot saved: /tmp/desktop-login.png') + } + + // Check connection dialog + const connectionDialog = page.locator('text=MQTT Connection').first() + if (await connectionDialog.isVisible()) { + console.log('\nConnection dialog detected') + await checkFormUsability(page, 'Desktop: Connection Dialog') + await checkTouchTargets(page, 'Desktop: Connection Dialog') + await page.screenshot({ path: '/tmp/desktop-connection.png' }) + console.log('Screenshot saved: /tmp/desktop-connection.png') + } + + } catch (error) { + console.error('Error during desktop exploration:', error) + } finally { + await context.close() + } +} + +async function exploreMobileUX(browser: Browser) { + console.log('\n\n========================================') + console.log('MOBILE UX EXPLORATION') + console.log('========================================\n') + + const context = await browser.newContext({ + ...PIXEL_6, + viewport: { width: 412, height: 914 } + }) + const page = await context.newPage() + + try { + await page.goto('http://localhost:3000', { waitUntil: 'networkidle' }) + await page.waitForTimeout(2000) + + // Check mobile-specific issues + await checkAccessibility(page, 'Mobile: Initial Load') + await checkResponsiveDesign(page, 'Mobile: Initial Load') + await checkTouchTargets(page, 'Mobile: Initial Load') + + // Take screenshot + await page.screenshot({ path: '/tmp/mobile-initial.png', fullPage: true }) + console.log('Screenshot saved: /tmp/mobile-initial.png') + + // Check login dialog on mobile + const loginDialog = page.locator('[role="dialog"]').first() + if (await loginDialog.isVisible()) { + console.log('\nLogin dialog on mobile detected') + await checkFormUsability(page, 'Mobile: Login Dialog') + await checkTouchTargets(page, 'Mobile: Login Dialog') + await page.screenshot({ path: '/tmp/mobile-login.png' }) + console.log('Screenshot saved: /tmp/mobile-login.png') + } + + // Check connection dialog on mobile + const connectionDialog = page.locator('text=MQTT Connection').first() + if (await connectionDialog.isVisible()) { + console.log('\nConnection dialog on mobile detected') + await checkFormUsability(page, 'Mobile: Connection Dialog') + await checkTouchTargets(page, 'Mobile: Connection Dialog') + + // Check if dialog fits on screen + const dialogBox = await connectionDialog.boundingBox() + if (dialogBox && dialogBox.width > 412) { + reportIssue({ + category: 'Responsive Design', + severity: 'high', + description: `Dialog width (${dialogBox.width}px) exceeds mobile viewport (412px)`, + location: 'Mobile: Connection Dialog', + recommendation: 'Make dialog responsive to fit within mobile viewports' + }) + } + + await page.screenshot({ path: '/tmp/mobile-connection.png' }) + console.log('Screenshot saved: /tmp/mobile-connection.png') + } + + } catch (error) { + console.error('Error during mobile exploration:', error) + } finally { + await context.close() + } +} + +async function main() { + console.log('Starting UX exploration of MQTT Explorer...\n') + console.log('This script will identify UX flaws in the application.\n') + + const browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }) + + try { + await exploreDesktopUX(browser) + await exploreMobileUX(browser) + + // Summary report + console.log('\n\n========================================') + console.log('UX ISSUES SUMMARY') + console.log('========================================\n') + + const critical = uxIssues.filter(i => i.severity === 'critical') + const high = uxIssues.filter(i => i.severity === 'high') + const medium = uxIssues.filter(i => i.severity === 'medium') + const low = uxIssues.filter(i => i.severity === 'low') + + console.log(`Total Issues Found: ${uxIssues.length}`) + console.log(` Critical: ${critical.length}`) + console.log(` High: ${high.length}`) + console.log(` Medium: ${medium.length}`) + console.log(` Low: ${low.length}`) + console.log('') + + if (critical.length > 0) { + console.log('CRITICAL ISSUES:') + critical.forEach((issue, i) => { + console.log(`${i + 1}. ${issue.description} (${issue.location})`) + }) + console.log('') + } + + if (high.length > 0) { + console.log('HIGH PRIORITY ISSUES:') + high.forEach((issue, i) => { + console.log(`${i + 1}. ${issue.description} (${issue.location})`) + }) + console.log('') + } + + // Save detailed report + const report = { + timestamp: new Date().toISOString(), + totalIssues: uxIssues.length, + bySeverity: { + critical: critical.length, + high: high.length, + medium: medium.length, + low: low.length + }, + issues: uxIssues + } + + const fs = require('fs') + fs.writeFileSync('/tmp/ux-report.json', JSON.stringify(report, null, 2)) + console.log('Detailed report saved to: /tmp/ux-report.json\n') + + } finally { + await browser.close() + } +} + +main().catch(console.error)