b
a
s
diff --git a/apps/web/src/components/SkipLinks/SkipLinks.tsx b/apps/web/src/components/SkipLinks/SkipLinks.tsx
new file mode 100644
index 00000000000..d8312bb2a7f
--- /dev/null
+++ b/apps/web/src/components/SkipLinks/SkipLinks.tsx
@@ -0,0 +1,23 @@
+/**
+ * SkipLinks Component
+ *
+ * Provides keyboard users a way to skip repetitive navigation and jump directly
+ * to main content areas. This is a WCAG 2.4.1 Level A requirement.
+ *
+ * The links are visually hidden until focused, appearing at the top of the page
+ * when a keyboard user tabs into them.
+ *
+ * @see https://www.w3.org/WAI/WCAG21/Understanding/bypass-blocks.html
+ */
+export function SkipLinks() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/VisuallyHidden/VisuallyHidden.test.tsx b/apps/web/src/components/VisuallyHidden/VisuallyHidden.test.tsx
new file mode 100644
index 00000000000..443f25270f0
--- /dev/null
+++ b/apps/web/src/components/VisuallyHidden/VisuallyHidden.test.tsx
@@ -0,0 +1,66 @@
+import { render } from '@testing-library/react';
+import { axe, toHaveNoViolations } from 'jest-axe';
+import { VisuallyHidden, LiveRegion } from './VisuallyHidden';
+
+expect.extend(toHaveNoViolations);
+
+describe('VisuallyHidden', () => {
+ it('should not have accessibility violations', async () => {
+ const { container } = render(
Hidden content);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should render content in the DOM but visually hidden', () => {
+ const { getByText } = render(
Screen reader only);
+ const element = getByText('Screen reader only');
+
+ expect(element).toBeInTheDocument();
+ expect(element.tagName).toBe('SPAN');
+ });
+
+ it('should have sr-only class equivalent styles', () => {
+ const { container } = render(
Test);
+ const span = container.querySelector('span');
+
+ const styles = window.getComputedStyle(span as Element);
+ expect(styles.position).toBe('absolute');
+ });
+});
+
+describe('LiveRegion', () => {
+ it('should not have accessibility violations', async () => {
+ const { container } = render(
Status message);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should have proper ARIA attributes for polite announcements', () => {
+ const { getByRole } = render(
Update successful);
+ const status = getByRole('status');
+
+ expect(status).toHaveAttribute('aria-live', 'polite');
+ expect(status).toHaveAttribute('aria-atomic', 'true');
+ });
+
+ it('should have proper ARIA attributes for assertive announcements', () => {
+ const { container } = render(
Critical error!);
+ const region = container.querySelector('[aria-live="assertive"]');
+
+ expect(region).toBeInTheDocument();
+ expect(region).toHaveAttribute('aria-atomic', 'true');
+ });
+
+ it('should not render when children is empty', () => {
+ const { container } = render(
{null});
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should conditionally render status messages', () => {
+ const { rerender, queryByRole } = render(
{false && 'Hidden'});
+ expect(queryByRole('status')).not.toBeInTheDocument();
+
+ rerender(
{true && 'Visible'});
+ expect(queryByRole('status')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/VisuallyHidden/VisuallyHidden.tsx b/apps/web/src/components/VisuallyHidden/VisuallyHidden.tsx
new file mode 100644
index 00000000000..2daef7a3f53
--- /dev/null
+++ b/apps/web/src/components/VisuallyHidden/VisuallyHidden.tsx
@@ -0,0 +1,65 @@
+import { ReactNode } from 'react';
+
+/**
+ * VisuallyHidden Component
+ *
+ * Hides content visually but keeps it accessible to screen readers.
+ * Useful for providing additional context that's redundant visually
+ * but necessary for screen reader users.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ *
+ * @see https://www.w3.org/WAI/WCAG21/Techniques/css/C7
+ */
+export function VisuallyHidden({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Live Region Component
+ *
+ * Announces dynamic content changes to screen readers.
+ * Use for status messages, notifications, and other live updates.
+ *
+ * @param assertive - If true, interrupts current announcement (use sparingly)
+ *
+ * @example
+ * ```tsx
+ *
+ * {copySuccess && "Address copied to clipboard"}
+ *
+ * ```
+ */
+export function LiveRegion({
+ children,
+ assertive = false,
+}: {
+ children: ReactNode;
+ assertive?: boolean;
+}) {
+ if (!children) return null;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/base-org/shared/TopNavigation/MenuDesktop.tsx b/apps/web/src/components/base-org/shared/TopNavigation/MenuDesktop.tsx
index 3cf35deb2cb..cf064578c30 100644
--- a/apps/web/src/components/base-org/shared/TopNavigation/MenuDesktop.tsx
+++ b/apps/web/src/components/base-org/shared/TopNavigation/MenuDesktop.tsx
@@ -66,7 +66,7 @@ export default function MenuDesktop({ links }: MenuDesktopProps) {
target={link.href.startsWith('https://') ? '_blank' : undefined}
onMouseEnter={onMouseEnterNavLink}
onClick={onLinkClick}
- className={`h-full rounded-md bg-opacity-0 px-6 py-2 text-sm transition-all duration-300 hover:bg-[#32353D] ${
+ className={`h-full rounded-md bg-opacity-0 px-6 py-3 min-h-[44px] flex items-center text-sm transition-all duration-300 hover:bg-[#32353D] ${
hoverIndex === index ? ' bg-[#32353D]' : ''
}`}
>
@@ -79,7 +79,7 @@ export default function MenuDesktop({ links }: MenuDesktopProps) {
{/* Sub Menu */}
{
+ test('should not have automatically detectable accessibility issues', async ({ page }) => {
+ await page.goto('/');
+ const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
+
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+
+ test('should have skip links that are keyboard accessible', async ({ page }) => {
+ await page.goto('/');
+
+ // Tab to skip link
+ await page.keyboard.press('Tab');
+
+ // Check if skip link is visible when focused
+ const skipLink = page.locator('a[href="#main-content"]');
+ await expect(skipLink).toBeFocused();
+ await expect(skipLink).toBeVisible();
+
+ // Press Enter to skip to main content
+ await page.keyboard.press('Enter');
+
+ // Verify focus moved to main content
+ const mainContent = page.locator('#main-content');
+ await expect(mainContent).toBeFocused();
+ });
+
+ test('should have proper heading hierarchy', async ({ page }) => {
+ await page.goto('/');
+
+ // Get all headings
+ const headings = await page.locator('h1, h2, h3, h4, h5, h6').all();
+
+ // Should have exactly one h1
+ const h1Count = await page.locator('h1').count();
+ expect(h1Count).toBe(1);
+
+ // Check no heading levels are skipped
+ const levels: number[] = [];
+ for (const heading of headings) {
+ const tagName = await heading.evaluate((el) => el.tagName);
+ const level = parseInt(tagName.charAt(1));
+ levels.push(level);
+ }
+
+ // Verify no skipped levels (e.g., h1 -> h3 without h2)
+ for (let i = 1; i < levels.length; i++) {
+ const diff = levels[i] - levels[i - 1];
+ expect(diff).toBeLessThanOrEqual(1);
+ }
+ });
+});
+
+test.describe('Keyboard Navigation', () => {
+ test('should be fully keyboard navigable', async ({ page }) => {
+ await page.goto('/');
+
+ // Tab through the page
+ for (let i = 0; i < 10; i++) {
+ await page.keyboard.press('Tab');
+ const focused = await page.evaluate(() => document.activeElement?.tagName);
+ expect(focused).toBeTruthy();
+ }
+ });
+
+ test('should have visible focus indicators', async ({ page }) => {
+ await page.goto('/');
+
+ await page.keyboard.press('Tab');
+ const focusedElement = page.locator(':focus-visible');
+
+ // Check if focus indicator is visible
+ await expect(focusedElement).toHaveCSS('outline-style', /solid|auto/);
+ await expect(focusedElement).toHaveCSS('outline-width', /2px|3px/);
+ });
+
+ test('should trap focus in modal dialogs', async ({ page }) => {
+ await page.goto('/');
+
+ // Find and open a modal (adjust selector based on your modals)
+ const modalTrigger = page.locator('[role="button"]').first();
+ if (await modalTrigger.count() > 0) {
+ await modalTrigger.click();
+
+ // Check if modal is open and has focus
+ const modal = page.locator('[role="dialog"]');
+ if (await modal.count() > 0) {
+ // Tab through modal elements
+ await page.keyboard.press('Tab');
+ const focusedInModal = await page.evaluate(() => {
+ const modal = document.querySelector('[role="dialog"]');
+ return modal?.contains(document.activeElement);
+ });
+
+ expect(focusedInModal).toBe(true);
+
+ // Press Escape to close
+ await page.keyboard.press('Escape');
+ await expect(modal).not.toBeVisible();
+ }
+ }
+ });
+});
+
+test.describe('Button Accessibility', () => {
+ test('should have accessible loading states', async ({ page }) => {
+ await page.goto('/');
+
+ // Find a button with loading state (if any)
+ const loadingButton = page.locator('button[aria-busy="true"]');
+ if (await loadingButton.count() > 0) {
+ expect(await loadingButton.getAttribute('aria-busy')).toBe('true');
+ expect(await loadingButton.getAttribute('aria-disabled')).toBe('true');
+
+ // Check for loading status message
+ const status = loadingButton.locator('[role="status"]');
+ await expect(status).toBeAttached();
+ }
+ });
+
+ test('should announce state changes to screen readers', async ({ page }) => {
+ await page.goto('/');
+
+ // Look for live regions
+ const liveRegions = page.locator('[aria-live]');
+ const count = await liveRegions.count();
+
+ if (count > 0) {
+ for (let i = 0; i < count; i++) {
+ const region = liveRegions.nth(i);
+ const ariaLive = await region.getAttribute('aria-live');
+ expect(['polite', 'assertive']).toContain(ariaLive);
+ }
+ }
+ });
+});
+
+test.describe('Form Accessibility', () => {
+ test('should have proper form labels', async ({ page }) => {
+ await page.goto('/');
+
+ // Get all inputs
+ const inputs = await page.locator('input:not([type="hidden"])').all();
+
+ for (const input of inputs) {
+ const id = await input.getAttribute('id');
+ const ariaLabel = await input.getAttribute('aria-label');
+ const ariaLabelledby = await input.getAttribute('aria-labelledby');
+
+ // Check if input has a label
+ if (id) {
+ const label = page.locator(`label[for="${id}"]`);
+ const hasLabel = (await label.count()) > 0;
+ const hasAriaLabel = ariaLabel !== null || ariaLabelledby !== null;
+
+ expect(hasLabel || hasAriaLabel).toBe(true);
+ }
+ }
+ });
+});
+
+test.describe('Color Contrast', () => {
+ test('should meet WCAG AA contrast requirements', async ({ page }) => {
+ await page.goto('/');
+
+ const accessibilityScanResults = await new AxeBuilder({ page })
+ .withTags(['wcag2aa', 'wcag21aa'])
+ .analyze();
+
+ const contrastViolations = accessibilityScanResults.violations.filter((v) =>
+ v.id.includes('color-contrast'),
+ );
+
+ expect(contrastViolations).toEqual([]);
+ });
+});
+
+test.describe('Images and Media', () => {
+ test('should have alt text for all images', async ({ page }) => {
+ await page.goto('/');
+
+ const images = await page.locator('img').all();
+
+ for (const img of images) {
+ const alt = await img.getAttribute('alt');
+ const ariaHidden = await img.getAttribute('aria-hidden');
+
+ // Image should either have alt text or be aria-hidden
+ expect(alt !== null || ariaHidden === 'true').toBe(true);
+ }
+ });
+});
+
+test.describe('Responsive Design', () => {
+ test('should support 200% text zoom', async ({ page }) => {
+ await page.goto('/');
+
+ // Simulate 200% zoom
+ await page.setViewportSize({ width: 640, height: 480 });
+ await page.evaluate(() => {
+ document.body.style.zoom = '2';
+ });
+
+ // Check for accessibility violations at 200% zoom
+ const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+
+ test('should be accessible on mobile devices', async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 667 }); // iPhone size
+ await page.goto('/');
+
+ const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+});
+
+test.describe('Motion Preferences', () => {
+ test('should respect prefers-reduced-motion', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.goto('/');
+
+ // Check if animations are reduced
+ const animated = await page.locator('[class*="animate"]').first();
+ if (await animated.count() > 0) {
+ const animationDuration = await animated.evaluate((el) =>
+ window.getComputedStyle(el).animationDuration,
+ );
+
+ // Animation should be very short or none
+ expect(parseFloat(animationDuration)).toBeLessThan(0.1);
+ }
+ });
+});
diff --git a/apps/web/tests/responsive.spec.ts b/apps/web/tests/responsive.spec.ts
new file mode 100644
index 00000000000..75ff05a87e1
--- /dev/null
+++ b/apps/web/tests/responsive.spec.ts
@@ -0,0 +1,322 @@
+import { test, expect, devices } from '@playwright/test';
+
+// Test responsive design across multiple viewport sizes
+const viewports = [
+ { name: 'iPhone SE', width: 375, height: 667 },
+ { name: 'iPhone 14 Pro', width: 393, height: 852 },
+ { name: 'iPad', width: 768, height: 1024 },
+ { name: 'iPad Pro landscape', width: 1024, height: 768 },
+ { name: 'Desktop', width: 1440, height: 900 },
+ { name: '4K Display', width: 2560, height: 1440 },
+];
+
+test.describe('Responsive Design Tests', () => {
+ viewports.forEach(({ name, width, height }) => {
+ test.describe(`${name} (${width}x${height})`, () => {
+ test.use({ viewport: { width, height } });
+
+ test('should not have horizontal scroll', async ({ page }) => {
+ await page.goto('/');
+
+ // Check that page width doesn't exceed viewport width
+ const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
+ const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+
+ expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); // +1 for rounding
+ });
+
+ test('should have readable text (minimum 16px on mobile)', async ({ page }) => {
+ if (width < 768) { // Mobile devices
+ await page.goto('/');
+
+ // Get all text elements
+ const textElements = await page.locator('p, span, a, button, h1, h2, h3, h4, h5, h6').all();
+
+ for (const element of textElements) {
+ const fontSize = await element.evaluate((el) => {
+ const style = window.getComputedStyle(el);
+ return parseFloat(style.fontSize);
+ });
+
+ // Skip hidden elements
+ const isVisible = await element.isVisible();
+ if (!isVisible) continue;
+
+ // Body text should be at least 16px on mobile (with small tolerance for decorative text)
+ const text = await element.textContent();
+ if (text && text.trim().length > 10) { // Only check substantial text
+ expect(fontSize).toBeGreaterThanOrEqual(14); // Allow 14px minimum
+ }
+ }
+ }
+ });
+
+ test('navigation dropdown should not overflow', async ({ page }) => {
+ await page.goto('/');
+
+ // Wait for page to load
+ await page.waitForLoadState('networkidle');
+
+ // Hover over navigation items to trigger dropdowns
+ const navLinks = await page.locator('nav a').all();
+
+ for (const link of navLinks.slice(0, 3)) { // Test first 3 nav items
+ await link.hover();
+ await page.waitForTimeout(300); // Wait for dropdown animation
+
+ // Check for horizontal overflow
+ const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
+ const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+
+ expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
+ }
+ });
+
+ test('images should not overflow containers', async ({ page }) => {
+ await page.goto('/');
+
+ const images = await page.locator('img').all();
+
+ for (const img of images) {
+ const isVisible = await img.isVisible();
+ if (!isVisible) continue;
+
+ const overflow = await img.evaluate((el) => {
+ const rect = el.getBoundingClientRect();
+ const parentRect = el.parentElement?.getBoundingClientRect();
+
+ if (!parentRect) return false;
+
+ return rect.width > parentRect.width || rect.height > parentRect.height;
+ });
+
+ expect(overflow).toBe(false);
+ }
+ });
+
+ test('touch targets should be at least 44x44px on mobile', async ({ page }) => {
+ if (width < 768) { // Mobile devices
+ await page.goto('/');
+
+ // Check all interactive elements
+ const interactiveElements = await page.locator('button, a[href], [role="button"]').all();
+
+ for (const element of interactiveElements) {
+ const isVisible = await element.isVisible();
+ if (!isVisible) continue;
+
+ const box = await element.boundingBox();
+ if (!box) continue;
+
+ // WCAG 2.5.8 requires 44x44px minimum
+ expect(box.height).toBeGreaterThanOrEqual(44);
+ expect(box.width).toBeGreaterThanOrEqual(44);
+ }
+ }
+ });
+ });
+ });
+
+ test.describe('Specific Component Tests', () => {
+ test('TopNavigation dropdown should be responsive', async ({ page }) => {
+ // Mobile
+ await page.setViewportSize({ width: 375, height: 667 });
+ await page.goto('/');
+
+ // Desktop menu should be hidden on mobile
+ const desktopMenu = page.locator('[class*="MenuDesktop"]');
+ if (await desktopMenu.count() > 0) {
+ const isVisible = await desktopMenu.isVisible();
+ // Desktop menu may or may not be present, just ensure no horizontal scroll
+ const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
+ const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+ expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
+ }
+
+ // Desktop
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.goto('/');
+
+ // Hover over a nav item
+ const navItem = page.locator('nav a').first();
+ if (await navItem.count() > 0) {
+ await navItem.hover();
+ await page.waitForTimeout(300);
+
+ // Check dropdown doesn't cause overflow
+ const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
+ const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+ expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
+ }
+ });
+
+ test('AgentKit Hero buttons should be full width on mobile', async ({ page }) => {
+ // Mobile
+ await page.setViewportSize({ width: 375, height: 667 });
+ await page.goto('/builders/agentkit');
+
+ const button = page.locator('button:has-text("npx create-agentkit-app")').first();
+
+ if (await button.count() > 0) {
+ const box = await button.boundingBox();
+ expect(box?.width).toBeGreaterThan(300); // Should be nearly full width on mobile
+ expect(box?.height).toBeGreaterThanOrEqual(44); // Touch target size
+ }
+
+ // Desktop
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.goto('/builders/agentkit');
+
+ if (await button.count() > 0) {
+ const box = await button.boundingBox();
+ expect(box?.width).toBeLessThan(400); // Should be auto width on desktop
+ }
+ });
+
+ test('Hero text should scale appropriately', async ({ page }) => {
+ // Mobile
+ await page.setViewportSize({ width: 375, height: 667 });
+ await page.goto('/');
+
+ const heading = page.locator('h1').first();
+
+ if (await heading.count() > 0) {
+ const mobileFontSize = await heading.evaluate((el) =>
+ parseFloat(window.getComputedStyle(el).fontSize)
+ );
+
+ // Desktop
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.reload();
+
+ const desktopFontSize = await heading.evaluate((el) =>
+ parseFloat(window.getComputedStyle(el).fontSize)
+ );
+
+ // Desktop should have larger text
+ expect(desktopFontSize).toBeGreaterThan(mobileFontSize);
+ }
+ });
+ });
+
+ test.describe('Layout Tests', () => {
+ test('should have proper spacing on all devices', async ({ page }) => {
+ for (const { name, width, height } of viewports) {
+ await page.setViewportSize({ width, height });
+ await page.goto('/');
+
+ // Check for content touching edges (should have padding)
+ const bodyPadding = await page.evaluate(() => {
+ const style = window.getComputedStyle(document.body);
+ return {
+ left: parseFloat(style.paddingLeft),
+ right: parseFloat(style.paddingRight),
+ };
+ });
+
+ // Main content should have some padding (at least on container)
+ const container = page.locator('main, [class*="container"]').first();
+ if (await container.count() > 0) {
+ const containerPadding = await container.evaluate((el) => {
+ const style = window.getComputedStyle(el);
+ return parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
+ });
+
+ expect(containerPadding).toBeGreaterThan(0);
+ }
+ }
+ });
+
+ test('grid layouts should adapt to screen size', async ({ page }) => {
+ await page.goto('/');
+
+ // Mobile: single column
+ await page.setViewportSize({ width: 375, height: 667 });
+ await page.waitForTimeout(100);
+
+ const grids = await page.locator('[class*="grid"]').all();
+ for (const grid of grids.slice(0, 5)) { // Test first 5 grids
+ const isVisible = await grid.isVisible();
+ if (!isVisible) continue;
+
+ const gridCols = await grid.evaluate((el) => {
+ const style = window.getComputedStyle(el);
+ return style.gridTemplateColumns;
+ });
+
+ // On mobile, should generally be 1 column or auto-fit
+ // (This is a soft check, some grids intentionally have multiple columns)
+ }
+
+ // Desktop: multiple columns
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.waitForTimeout(100);
+
+ // Just verify no overflow occurs
+ const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
+ const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+ expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
+ });
+ });
+
+ test.describe('Accessibility & Responsiveness Combined', () => {
+ test('focus indicators should be visible at all viewport sizes', async ({ page }) => {
+ for (const { width, height } of viewports.slice(0, 3)) { // Test 3 viewports
+ await page.setViewportSize({ width, height });
+ await page.goto('/');
+
+ // Tab through interactive elements
+ await page.keyboard.press('Tab');
+ await page.waitForTimeout(100);
+
+ const focusedElement = await page.evaluate(() => {
+ const el = document.activeElement;
+ if (!el) return null;
+
+ const style = window.getComputedStyle(el);
+ return {
+ outline: style.outline,
+ outlineWidth: style.outlineWidth,
+ outlineStyle: style.outlineStyle,
+ };
+ });
+
+ // Should have some kind of focus indicator
+ if (focusedElement) {
+ const hasOutline =
+ focusedElement.outlineWidth !== '0px' ||
+ focusedElement.outline !== 'none';
+ expect(hasOutline).toBe(true);
+ }
+ }
+ });
+
+ test('modals should be responsive and accessible', async ({ page }) => {
+ await page.goto('/');
+
+ // Try to find and open a modal (if one exists)
+ const modalTrigger = page.locator('[aria-haspopup="dialog"], [data-state="closed"]').first();
+
+ if (await modalTrigger.count() > 0) {
+ // Mobile
+ await page.setViewportSize({ width: 375, height: 667 });
+ await modalTrigger.click();
+ await page.waitForTimeout(300);
+
+ const modal = page.locator('[role="dialog"]');
+ if (await modal.count() > 0) {
+ const box = await modal.boundingBox();
+ expect(box?.width).toBeLessThanOrEqual(375);
+
+ // Should be scrollable if content is too tall
+ const isScrollable = await modal.evaluate((el) => {
+ return el.scrollHeight > el.clientHeight;
+ });
+
+ // Either fits in viewport or is scrollable
+ expect(box?.height || 0).toBeGreaterThan(0);
+ }
+ }
+ });
+ });
+});
diff --git a/lighthouserc.json b/lighthouserc.json
new file mode 100644
index 00000000000..f1e2a68704e
--- /dev/null
+++ b/lighthouserc.json
@@ -0,0 +1,42 @@
+{
+ "ci": {
+ "collect": {
+ "numberOfRuns": 1,
+ "startServerCommand": "yarn workspace @app/web start",
+ "url": ["http://localhost:3000"]
+ },
+ "assert": {
+ "preset": "lighthouse:recommended",
+ "assertions": {
+ "categories:performance": ["warn", {"minScore": 0.8}],
+ "categories:accessibility": ["error", {"minScore": 0.9}],
+ "categories:best-practices": ["warn", {"minScore": 0.85}],
+ "categories:seo": ["warn", {"minScore": 0.85}],
+ "color-contrast": "error",
+ "heading-order": "error",
+ "html-has-lang": "error",
+ "image-alt": "error",
+ "label": "error",
+ "link-name": "error",
+ "list": "warn",
+ "listitem": "warn",
+ "meta-viewport": "error",
+ "document-title": "error",
+ "valid-lang": "error",
+ "aria-allowed-attr": "error",
+ "aria-required-attr": "error",
+ "aria-valid-attr-value": "error",
+ "aria-valid-attr": "error",
+ "button-name": "error",
+ "bypass": "error",
+ "duplicate-id-aria": "error",
+ "frame-title": "error",
+ "tabindex": "error",
+ "td-headers-attr": "warn"
+ }
+ },
+ "upload": {
+ "target": "temporary-public-storage"
+ }
+ }
+}