diff --git a/.changeset/fix-back-merge-loop.md b/.changeset/fix-back-merge-loop.md new file mode 100644 index 00000000..b2d83090 --- /dev/null +++ b/.changeset/fix-back-merge-loop.md @@ -0,0 +1,5 @@ +--- +"@stackwright/cli": patch +--- + +Upgrade the back-merge rebase conflict handler from a one-shot block to a loop. Dev can accumulate multiple alpha-bump commits that all modified `.changeset/pre.json` — the previous one-shot `||{ }` only resolved the first conflict. The loop now runs until all pre.json conflicts are resolved or an unexpected conflict is encountered. diff --git a/.changeset/fix-back-merge-pre-json-conflict.md b/.changeset/fix-back-merge-pre-json-conflict.md new file mode 100644 index 00000000..5bb72d97 --- /dev/null +++ b/.changeset/fix-back-merge-pre-json-conflict.md @@ -0,0 +1,5 @@ +--- +"@stackwright/cli": patch +--- + +Fix back-merge into dev failing with a modify/delete conflict on `.changeset/pre.json` during rebase. The release workflow deletes this file via `changeset pre exit`, but dev's alpha-bump commits still reference it. The rebase now explicitly resolves the conflict by accepting main's deletion and continuing. diff --git a/.changeset/fix-js-yaml-override-conflict.md b/.changeset/fix-js-yaml-override-conflict.md new file mode 100644 index 00000000..c5edcbf0 --- /dev/null +++ b/.changeset/fix-js-yaml-override-conflict.md @@ -0,0 +1,5 @@ +--- +"@stackwright/cli": patch +--- + +Fix js-yaml version override conflict in pnpm overrides that caused `yaml.safeLoad is removed` errors when running `changeset pre exit` in CI. The global `js-yaml: >=4.1.1` override was stomping the scoped `read-yaml-file>js-yaml: ^3` override, forcing js-yaml v4 into read-yaml-file which doesn't support it. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..32022c0b --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,29 @@ +{ + "mode": "pre", + "tag": "alpha", + "initialVersions": { + "stackwright-docs": "0.1.6", + "@stackwright/build-scripts": "0.7.2", + "@stackwright/cli": "0.8.5", + "@stackwright/collections": "0.1.1", + "@stackwright/core": "0.8.4", + "@stackwright/e2e": "0.3.0", + "@stackwright/hooks-registry": "0.1.1", + "@stackwright/icons": "0.5.2", + "launch-stackwright": "0.2.5", + "@stackwright/maplibre": "2.0.4", + "@stackwright/mcp": "0.4.5", + "@stackwright/nextjs": "0.5.3", + "@stackwright/otters": "0.2.1", + "@stackwright/sbom-generator": "0.2.1", + "@stackwright/scaffold-core": "0.3.1", + "@stackwright/themes": "0.5.3", + "@stackwright/types": "1.5.0", + "@stackwright/ui-shadcn": "0.1.3" + }, + "changesets": [ + "fix-back-merge-loop", + "fix-back-merge-pre-json-conflict", + "fix-js-yaml-override-conflict" + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c1928b3..ad7f0ca4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -811,6 +811,8 @@ Common issues and fixes: See `packages/e2e/TESTING_INFRASTRUCTURE.md` for detailed accessibility guide. +> **CI browser scope:** In CI, the accessibility workflow runs **Chromium only** (Firefox/WebKit binaries are not installed in that job, by design). `Executable doesn't exist` errors for Firefox/WebKit in the a11y job logs are expected — not an infra gap. The job is non-blocking (`|| true`) so a11y issues are visible without blocking merges. To run a11y tests against all browsers locally, first run `pnpm --filter @stackwright/e2e exec playwright install` to install all binaries. + --- ## Cross-Browser Testing @@ -827,6 +829,8 @@ E2E tests run on multiple browsers and viewports in CI. **Total**: 6 test runs per PR (3 browsers × 2 viewports) +> **Note:** This matrix is for the main E2E suite. The **accessibility tests** (`tests/a11y/`) run **Chromium only** in CI — see below. + ### Running Cross-Browser Tests Locally ```bash diff --git a/examples/stackwright-docs/stackwright.yml b/examples/stackwright-docs/stackwright.yml index 1dc764be..c7ae782d 100644 --- a/examples/stackwright-docs/stackwright.yml +++ b/examples/stackwright-docs/stackwright.yml @@ -77,7 +77,7 @@ customTheme: text: "#FFFFFF" textSecondary: "#B0BEC5" darkColors: - primary: "#D97706" # Amber 600 - darker for dark mode + primary: "#92400e" # Amber 800 — WCAG AA compliant on light dark-mode backgrounds (~5.8:1 on #F5F5F5) secondary: "#0288D1" accent: "#F59E0B" # Amber 500 background: "#FDFDFD" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 72a4990c..1b3ee598 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @stackwright/cli +## 0.8.6-alpha.0 + +### Patch Changes + +- 669aeee: Upgrade the back-merge rebase conflict handler from a one-shot block to a loop. Dev can accumulate multiple alpha-bump commits that all modified `.changeset/pre.json` — the previous one-shot `||{ }` only resolved the first conflict. The loop now runs until all pre.json conflicts are resolved or an unexpected conflict is encountered. +- 3819871: Fix back-merge into dev failing with a modify/delete conflict on `.changeset/pre.json` during rebase. The release workflow deletes this file via `changeset pre exit`, but dev's alpha-bump commits still reference it. The rebase now explicitly resolves the conflict by accepting main's deletion and continuing. +- cd01671: Fix js-yaml version override conflict in pnpm overrides that caused `yaml.safeLoad is removed` errors when running `changeset pre exit` in CI. The global `js-yaml: >=4.1.1` override was stomping the scoped `read-yaml-file>js-yaml: ^3` override, forcing js-yaml v4 into read-yaml-file which doesn't support it. + ## 0.8.5 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 6435aefd..cb3bca5f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/cli", - "version": "0.8.5", + "version": "0.8.6-alpha.0", "description": "CLI for Stackwright framework", "license": "MIT", "repository": { diff --git a/packages/core/package.json b/packages/core/package.json index 098549ef..6614b3ea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,6 +39,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.11", "@stackwright/themes": "workspace:*", "@stackwright/types": "workspace:*", "fuse.js": "^7.1.0", diff --git a/packages/core/src/components/base/CodeBlock.tsx b/packages/core/src/components/base/CodeBlock.tsx index df879b57..c288021b 100644 --- a/packages/core/src/components/base/CodeBlock.tsx +++ b/packages/core/src/components/base/CodeBlock.tsx @@ -3,6 +3,7 @@ import { CodeBlockContent } from '@stackwright/types'; import { useSafeTheme, useSafeColorMode } from '../../hooks/useSafeTheme'; import { resolveBackground } from '../../utils/resolveBackground'; import { highlightCode, getTokenColor, HighlightToken } from '../../utils/prismHighlighter'; +import { hexToRgb, getLuminance } from '../../utils/colorUtils'; /** * Split a flat token list into per-line groups so each line can be @@ -25,7 +26,9 @@ function splitTokensByLine(tokens: HighlightToken[]): HighlightToken[][] { export function CodeBlock({ code, language, lineNumbers = false, background }: CodeBlockContent) { const theme = useSafeTheme(); const resolvedColorMode = useSafeColorMode(); - const isDark = resolvedColorMode === 'dark'; + const surfaceRgb = hexToRgb(theme.colors.surface); + const surfaceLuminance = surfaceRgb ? getLuminance(surfaceRgb.r, surfaceRgb.g, surfaceRgb.b) : 0; + const isDarkSurface = surfaceLuminance < 0.179; const tokens = highlightCode(code.trimEnd(), language); const tokenLines = splitTokensByLine(tokens); @@ -66,6 +69,7 @@ export function CodeBlock({ code, language, lineNumbers = false, background }: C )}
                 {lineTokens.length > 0
                   ? lineTokens.map((t, j) => {
-                      const color = getTokenColor(t.type, isDark);
+                      const color = getTokenColor(t.type, isDarkSurface);
                       return color ? (
                         
                           {t.content}
diff --git a/packages/core/src/components/base/Faq.tsx b/packages/core/src/components/base/Faq.tsx
index 6189136a..c8332073 100644
--- a/packages/core/src/components/base/Faq.tsx
+++ b/packages/core/src/components/base/Faq.tsx
@@ -1,13 +1,27 @@
 import React from 'react';
+import * as Accordion from '@radix-ui/react-accordion';
 import { FaqContent } from '@stackwright/types';
 import { useSafeColorMode, useSafeTheme } from '../../hooks/useSafeTheme';
 import { resolveColor } from '../../utils/colorUtils';
 import { resolveBackground } from '../../utils/resolveBackground';
 import { getThemeShadow } from '../../utils/shadowUtils';
 
+/**
+ * FAQ accordion component built on @radix-ui/react-accordion.
+ *
+ * Replaces the previous 
/ implementation which overrode + * native disclosure widget behavior (listStyle: none + display: flex on + * ) and caused a keyboard trap in Chromium. Radix Accordion handles + * all keyboard interactions correctly: Enter/Space to toggle, Tab to move + * between items, no traps. (WCAG 2.1.1, 2.1.2) + * + * type="multiple" lets users keep several answers open at once — friendlier + * for scanning a docs page than forcing single-open. + */ export function Faq({ heading, items, background }: FaqContent) { const theme = useSafeTheme(); const resolvedColorMode = useSafeColorMode(); + const [openItems, setOpenItems] = React.useState([]); const headingColor = resolveColor( heading?.textColor ? heading.textColor : theme.colors.primary, @@ -32,7 +46,12 @@ export function Faq({ heading, items, background }: FaqContent) { {heading.text} )} -
— style it as the flex column container */} + - {items.map((item, index) => ( -
- - {item.question} - - -
{ + const value = `item-${index}`; + const isOpen = openItems.includes(value); + + return ( + - {item.answer} -
-
- ))} -
+ {/* + * Accordion.Header defaults to

. Using asChild +
to + * avoid stacking multiple h3s alongside the section heading above — + * the WAI-ARIA accordion pattern requires a button inside a heading, + * but heading level depends on page context. Omitting the heading + * element here keeps Radix's ARIA button management while leaving + * heading hierarchy to the page author. + */} + +
+ + {item.question} + + +
+
+ + {/* Accordion.Content unmounts from DOM when closed (no forceMount) */} + +
+ {item.answer} +
+
+ + ); + })} + ); } diff --git a/packages/core/src/components/base/ThemedButton.tsx b/packages/core/src/components/base/ThemedButton.tsx index b786c613..ef692693 100644 --- a/packages/core/src/components/base/ThemedButton.tsx +++ b/packages/core/src/components/base/ThemedButton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { ButtonContent } from '@stackwright/types'; import { useSafeTheme } from '../../hooks/useSafeTheme'; -import { getHoverColor, resolveColor } from '../../utils/colorUtils'; +import { getHoverColor, resolveColor, getBetterTextColor } from '../../utils/colorUtils'; import { Media } from '../media/Media'; interface ThemedButtonProps { @@ -40,7 +40,9 @@ export function ThemedButton({ button, className }: ThemedButtonProps) { : theme.colors.primary; const buttonTextColor = button.textColor ? resolveColor(button.textColor, theme.colors) - : theme.colors.text; + : button.variant === undefined || button.variant === 'contained' + ? getBetterTextColor('#FFFFFF', '#1A1A1A', buttonColor) + : theme.colors.text; const hoverColor = getHoverColor(buttonColor); const buttonSize = button.variantSize || 'medium'; diff --git a/packages/core/src/components/media/Media.tsx b/packages/core/src/components/media/Media.tsx index f917946a..dd7191cf 100644 --- a/packages/core/src/components/media/Media.tsx +++ b/packages/core/src/components/media/Media.tsx @@ -129,47 +129,65 @@ const renderImageMedia = (content: MediaItem) => { }; const renderVideoMedia = (content: VideoMediaItem) => { + const accessibleLabel = content.alt || content.label || 'Video'; return ( - + +
); }; /** Renders a basic

{item.event}

diff --git a/packages/core/src/components/structural/DefaultPageLayout.tsx b/packages/core/src/components/structural/DefaultPageLayout.tsx index b22bca98..dfe9e0d4 100644 --- a/packages/core/src/components/structural/DefaultPageLayout.tsx +++ b/packages/core/src/components/structural/DefaultPageLayout.tsx @@ -54,6 +54,53 @@ export default function DefaultPageLayout(pageContent: PageContent) { return (
+ { + Object.assign(e.currentTarget.style, { + left: '50%', + transform: 'translateX(-50%)', + top: '1rem', + width: 'auto', + height: 'auto', + overflow: 'visible', + padding: '0.5rem 1.25rem', + backgroundColor: '#000', + color: '#fff', + borderRadius: '4px', + textDecoration: 'none', + fontWeight: 'bold', + fontSize: '0.875rem', + }); + }} + onBlur={(e) => { + Object.assign(e.currentTarget.style, { + left: '-9999px', + top: 'auto', + width: '1px', + height: '1px', + overflow: 'hidden', + transform: '', + padding: '', + backgroundColor: '', + color: '', + borderRadius: '', + fontWeight: '', + fontSize: '', + }); + }} + > + Skip to main content + {resolvedSidebar && ( )}
+ { + Object.assign(e.currentTarget.style, { + left: '50%', + transform: 'translateX(-50%)', + top: '1rem', + width: 'auto', + height: 'auto', + overflow: 'visible', + padding: '0.5rem 1.25rem', + backgroundColor: '#000', + color: '#fff', + borderRadius: '4px', + textDecoration: 'none', + fontWeight: 'bold', + fontSize: '0.875rem', + }); + }} + onBlur={(e) => { + Object.assign(e.currentTarget.style, { + left: '-9999px', + top: 'auto', + width: '1px', + height: '1px', + overflow: 'hidden', + transform: '', + padding: '', + backgroundColor: '', + color: '', + borderRadius: '', + fontWeight: '', + fontSize: '', + }); + }} + > + Skip to main content + )} -
+
{renderContent(pageContent, { contentItemsOnly: true })}
diff --git a/packages/core/test/components/content-types.test.tsx b/packages/core/test/components/content-types.test.tsx index 3d09902d..04cd4d7b 100644 --- a/packages/core/test/components/content-types.test.tsx +++ b/packages/core/test/components/content-types.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { FeatureList } from '../../src/components/base/FeatureList'; import { TestimonialGrid } from '../../src/components/base/TestimonialGrid'; @@ -62,7 +62,7 @@ describe('TestimonialGrid', () => { }); describe('Faq', () => { - it('renders FAQ items as details/summary elements', () => { + it('renders FAQ items as an accessible accordion', () => { render( { /> ); expect(screen.getByText('FAQ')).toBeInTheDocument(); - expect(screen.getByText('What is this?')).toBeInTheDocument(); + // Question triggers are visible in collapsed state + expect(screen.getByRole('button', { name: 'What is this?' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Is it free?' })).toBeInTheDocument(); + // Answer is hidden until the user expands the accordion item + const trigger = screen.getByRole('button', { name: 'What is this?' }); + fireEvent.click(trigger); expect(screen.getByText('A framework.')).toBeInTheDocument(); - expect(screen.getByText('Is it free?')).toBeInTheDocument(); }); }); diff --git a/packages/e2e/TESTING_INFRASTRUCTURE.md b/packages/e2e/TESTING_INFRASTRUCTURE.md index 774ef842..81054f2d 100644 --- a/packages/e2e/TESTING_INFRASTRUCTURE.md +++ b/packages/e2e/TESTING_INFRASTRUCTURE.md @@ -405,6 +405,20 @@ pnpm --filter @stackwright/e2e exec playwright test a11y/keyboard-navigation.spe pnpm --filter @stackwright/e2e exec playwright test tests/a11y/ ``` +#### CI Browser Scope + +> **By design:** The accessibility CI workflow runs **Chromium only** — Firefox, WebKit, and Mobile Safari browser binaries are intentionally not installed in the a11y job. This keeps the job fast (no ~200MB browser downloads) while still catching the vast majority of WCAG issues, which are framework-level rather than browser-specific. +> +> If you see `browserType.launch: Executable doesn't exist` errors for Firefox or WebKit in the a11y job logs, **this is expected** — not an infrastructure gap. +> +> The a11y job is also **non-blocking** (`|| true`) — it reports issues without failing the pipeline, giving us visibility without blocking merges on a11y-in-progress work. +> +> To run a11y tests against all browsers locally: +> ```bash +> pnpm --filter @stackwright/e2e exec playwright install # installs all browsers +> pnpm --filter @stackwright/e2e exec playwright test tests/a11y/ +> ``` + --- ### Performance Benchmarks @@ -593,6 +607,7 @@ All tests run automatically in GitHub Actions on every PR. | `coverage.yml` | Push to PR | Coverage report | ❌ No | | `visual-regression.yml` | Push to PR | Visual tests | ✅ Yes | | `e2e.yml` | Push to PR | Full E2E suite | ✅ Yes | +| `accessibility.yml` | Push to PR | WCAG + keyboard (Chromium only, non-blocking) | ❌ No | ### Test Matrix @@ -604,6 +619,8 @@ E2E tests run on multiple browsers and OSes: **Total**: 6 test runs per PR (3 browsers × 2 viewports) +> **Note:** The above matrix applies to the main E2E suite (`e2e.yml`). The **accessibility workflow** (`accessibility.yml`) runs **Chromium only** by design — see [Accessibility Testing → CI Browser Scope](#ci-browser-scope) for details. + ### CI Performance | Job | Avg Duration | Timeout | diff --git a/packages/e2e/tests/a11y/keyboard-navigation.spec.ts b/packages/e2e/tests/a11y/keyboard-navigation.spec.ts index d925b32d..e7d6f53b 100644 --- a/packages/e2e/tests/a11y/keyboard-navigation.spec.ts +++ b/packages/e2e/tests/a11y/keyboard-navigation.spec.ts @@ -36,13 +36,13 @@ async function hasFocusIndicator(element: Locator): Promise { }); // Check if any focus indicator is present - const hasOutline = styles.outlineStyle !== 'none' && - styles.outlineWidth !== '0px' && - parseFloat(styles.outlineWidth) > 0; - - const hasBoxShadow = styles.boxShadow !== 'none' && - !styles.boxShadow.includes('0px 0px 0px'); - + const hasOutline = + styles.outlineStyle !== 'none' && + styles.outlineWidth !== '0px' && + parseFloat(styles.outlineWidth) > 0; + + const hasBoxShadow = styles.boxShadow !== 'none' && !styles.boxShadow.includes('0px 0px 0px'); + return hasOutline || hasBoxShadow; } @@ -101,7 +101,7 @@ for (const { path: pagePath, name } of PAGES) { // Get all interactive elements const interactiveElements = await getInteractiveElements(page); - + if (interactiveElements.length === 0) { console.warn(`⚠️ No interactive elements found on ${name}`); return; // Skip test if no interactive elements @@ -114,14 +114,14 @@ for (const { path: pagePath, name } of PAGES) { for (let i = 0; i < maxTabs; i++) { await page.keyboard.press('Tab'); const focused = await getFocusedElement(page); - const tagName = await focused.evaluate(el => { + const tagName = await focused.evaluate((el) => { if (!el) return 'none'; const tag = el.tagName.toLowerCase(); const role = el.getAttribute('role'); const type = el.getAttribute('type'); return role ? `${tag}[role=${role}]` : type ? `${tag}[type=${type}]` : tag; }); - + focusedElements.push(tagName); // If we've cycled back to the start or hit body/html, we're done @@ -131,13 +131,14 @@ for (const { path: pagePath, name } of PAGES) { } // Should have focused on at least some interactive elements - const interactiveTags = focusedElements.filter(tag => - tag.startsWith('a') || - tag.startsWith('button') || - tag.startsWith('input') || - tag.startsWith('select') || - tag.startsWith('textarea') || - tag.includes('[role=') + const interactiveTags = focusedElements.filter( + (tag) => + tag.startsWith('a') || + tag.startsWith('button') || + tag.startsWith('input') || + tag.startsWith('select') || + tag.startsWith('textarea') || + tag.includes('[role=') ); expect( @@ -155,31 +156,28 @@ for (const { path: pagePath, name } of PAGES) { await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); - + const forwardElement = await getFocusedElement(page); - const forwardTag = await forwardElement.evaluate(el => el?.tagName); + const forwardTag = await forwardElement.evaluate((el) => el?.tagName); // Tab backward await page.keyboard.press('Shift+Tab'); - + const backwardElement = await getFocusedElement(page); - const backwardTag = await backwardElement.evaluate(el => el?.tagName); + const backwardTag = await backwardElement.evaluate((el) => el?.tagName); // Should have moved to a different element - const forwardHtml = await forwardElement.evaluate(el => el?.outerHTML.substring(0, 100)); - const backwardHtml = await backwardElement.evaluate(el => el?.outerHTML.substring(0, 100)); - - expect( - forwardHtml !== backwardHtml, - 'Shift+Tab should move focus backward' - ).toBe(true); + const forwardHtml = await forwardElement.evaluate((el) => el?.outerHTML.substring(0, 100)); + const backwardHtml = await backwardElement.evaluate((el) => el?.outerHTML.substring(0, 100)); + + expect(forwardHtml !== backwardHtml, 'Shift+Tab should move focus backward').toBe(true); }); test('Focus indicators are visible on all interactive elements', async ({ page }) => { await page.goto(pagePath, { waitUntil: 'networkidle' }); const interactiveElements = await getInteractiveElements(page); - + if (interactiveElements.length === 0) { console.warn(`⚠️ No interactive elements found on ${name}`); return; @@ -195,25 +193,29 @@ for (const { path: pagePath, name } of PAGES) { for (const element of elementsToTest) { // Focus the element await element.focus(); - + // Wait a bit for focus styles to apply await page.waitForTimeout(50); // Check for focus indicator const hasIndicator = await hasFocusIndicator(element); - + if (hasIndicator) { elementsWithFocusIndicator++; } else { - const tag = await element.evaluate(el => - `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.className ? '.' + el.className.split(' ')[0] : ''}` + const tag = await element.evaluate( + (el) => + `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.className ? '.' + el.className.split(' ')[0] : ''}` ); elementsMissingIndicator.push(tag); } } if (elementsMissingIndicator.length > 0) { - console.warn(`⚠️ ${name}: ${elementsMissingIndicator.length} elements missing focus indicators:`, elementsMissingIndicator); + console.warn( + `⚠️ ${name}: ${elementsMissingIndicator.length} elements missing focus indicators:`, + elementsMissingIndicator + ); } // At least 70% of interactive elements should have visible focus indicators @@ -223,14 +225,16 @@ for (const { path: pagePath, name } of PAGES) { `At least 70% of interactive elements should have visible focus indicators on ${name}` ).toBeGreaterThanOrEqual(70); - console.log(`✅ ${name}: ${elementsWithFocusIndicator}/${elementsToTest.length} elements have focus indicators (${percentage.toFixed(1)}%)`); + console.log( + `✅ ${name}: ${elementsWithFocusIndicator}/${elementsToTest.length} elements have focus indicators (${percentage.toFixed(1)}%)` + ); }); test('No keyboard traps exist on the page', async ({ page }) => { await page.goto(pagePath, { waitUntil: 'networkidle' }); const interactiveElements = await getInteractiveElements(page); - + if (interactiveElements.length === 0) { return; // Skip if no interactive elements } @@ -244,7 +248,17 @@ for (const { path: pagePath, name } of PAGES) { for (let i = 0; i < maxTabs; i++) { await page.keyboard.press('Tab'); const focused = await getFocusedElement(page); - const elementId = await focused.evaluate(el => { + + // Native video/audio controls live in shadow DOM — document.activeElement + // always reports the media element itself while Tab moves through its + // internal controls, which falsely triggers the stuck-element detector. + const isNativeMedia = await focused.evaluate((el) => { + const tag = (el as HTMLElement).tagName; + return tag === 'VIDEO' || tag === 'AUDIO'; + }); + if (isNativeMedia) continue; + + const elementId = await focused.evaluate((el) => { if (!el) return 'none'; return el.outerHTML.substring(0, 100); }); @@ -254,7 +268,7 @@ for (const { path: pagePath, name } of PAGES) { // Check if we're stuck on the same element for 3+ tabs if (focusHistory.length >= 4) { const lastFour = focusHistory.slice(-4); - if (lastFour.every(id => id === lastFour[0])) { + if (lastFour.every((id) => id === lastFour[0])) { trapDetected = true; trapElement = elementId; break; @@ -277,7 +291,7 @@ for (const { path: pagePath, name } of PAGES) { const inputs = await page.locator('input, select, textarea').all(); const allElements = [...buttons, ...links, ...inputs]; - + if (allElements.length === 0) { console.warn(`⚠️ No interactive elements found on ${name}`); return; @@ -295,28 +309,34 @@ for (const { path: pagePath, name } of PAGES) { try { await element.focus(); await page.waitForTimeout(50); - + const focused = await getFocusedElement(page); - const isFocused = await focused.evaluate((el, targetEl) => el === targetEl, await element.elementHandle()); - + const isFocused = await focused.evaluate( + (el, targetEl) => el === targetEl, + await element.elementHandle() + ); + if (isFocused) { reachableCount++; } else { - const tag = await element.evaluate(el => - `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}` + const tag = await element.evaluate( + (el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}` ); unreachableElements.push(tag); } } catch (e) { - const tag = await element.evaluate(el => - `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}` + const tag = await element.evaluate( + (el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}` ); unreachableElements.push(tag); } } if (unreachableElements.length > 0) { - console.warn(`⚠️ ${name}: ${unreachableElements.length} elements not keyboard-reachable:`, unreachableElements); + console.warn( + `⚠️ ${name}: ${unreachableElements.length} elements not keyboard-reachable:`, + unreachableElements + ); } // At least 90% should be reachable @@ -326,7 +346,9 @@ for (const { path: pagePath, name } of PAGES) { `At least 90% of interactive elements should be keyboard-reachable on ${name}` ).toBeGreaterThanOrEqual(90); - console.log(`✅ ${name}: ${reachableCount}/${sample.length} interactive elements are keyboard-reachable (${percentage.toFixed(1)}%)`); + console.log( + `✅ ${name}: ${reachableCount}/${sample.length} interactive elements are keyboard-reachable (${percentage.toFixed(1)}%)` + ); }); test('Enter key activates focused buttons and links', async ({ page }) => { @@ -347,11 +369,11 @@ for (const { path: pagePath, name } of PAGES) { } const button = visibleButtons[0]; - + // Focus and activate with Enter await button.focus(); await page.keyboard.press('Enter'); - + // Small delay to allow any click handlers to execute await page.waitForTimeout(100); @@ -378,11 +400,11 @@ for (const { path: pagePath, name } of PAGES) { } const button = visibleButtons[0]; - + // Focus and activate with Space await button.focus(); await page.keyboard.press('Space'); - + // Small delay to allow any click handlers to execute await page.waitForTimeout(100); @@ -399,24 +421,26 @@ test.describe('Site-wide Keyboard Navigation', () => { // Press Tab to potentially reveal skip link await page.keyboard.press('Tab'); - + // Look for skip link (common patterns) - const skipLink = page.locator('a[href="#main"], a[href="#content"], a[href="#main-content"]').first(); - + const skipLink = page + .locator('a[href="#main"], a[href="#content"], a[href="#main-content"]') + .first(); + if (await skipLink.isVisible()) { // Activate the skip link await skipLink.focus(); await page.keyboard.press('Enter'); - + // Check that focus moved to main content const focused = await getFocusedElement(page); - const focusedId = await focused.evaluate(el => el?.id || ''); - + const focusedId = await focused.evaluate((el) => el?.id || ''); + expect( - ['main', 'content', 'main-content'].some(id => focusedId.includes(id)), + ['main', 'content', 'main-content'].some((id) => focusedId.includes(id)), 'Skip link should move focus to main content area' ).toBe(true); - + console.log('✅ Skip link is functional'); } else { console.warn('⚠️ No skip link found - consider adding one for better accessibility'); @@ -427,8 +451,10 @@ test.describe('Site-wide Keyboard Navigation', () => { await page.goto('/', { waitUntil: 'networkidle' }); // Look for any button that might open a modal/dialog - const modalTriggers = await page.locator('[aria-haspopup="dialog"], [data-modal], button:has-text("Open")').all(); - + const modalTriggers = await page + .locator('[aria-haspopup="dialog"], [data-modal], button:has-text("Open")') + .all(); + if (modalTriggers.length === 0) { console.warn('⚠️ No modal triggers found to test'); return; @@ -441,7 +467,7 @@ test.describe('Site-wide Keyboard Navigation', () => { // Look for modal/dialog const modal = page.locator('[role="dialog"], [role="alertdialog"], .modal').first(); - + if (await modal.isVisible()) { // Press Escape to close await page.keyboard.press('Escape'); @@ -450,7 +476,7 @@ test.describe('Site-wide Keyboard Navigation', () => { // Modal should be closed const stillVisible = await modal.isVisible().catch(() => false); expect(stillVisible, 'Modal should close when Escape is pressed').toBe(false); - + console.log('✅ Modal closes with Escape key'); } else { console.warn('⚠️ No modal appeared after clicking trigger'); @@ -461,15 +487,17 @@ test.describe('Site-wide Keyboard Navigation', () => { await page.goto('/', { waitUntil: 'networkidle' }); // Look for dropdown/menu triggers - const menuTriggers = await page.locator('[aria-haspopup="menu"], [role="button"][aria-expanded]').all(); - + const menuTriggers = await page + .locator('[aria-haspopup="menu"], [role="button"][aria-expanded]') + .all(); + if (menuTriggers.length === 0) { console.warn('⚠️ No dropdown menus found to test'); return; } const trigger = menuTriggers[0]; - + // Focus and activate with keyboard await trigger.focus(); await page.keyboard.press('Enter'); @@ -477,10 +505,10 @@ test.describe('Site-wide Keyboard Navigation', () => { // Menu should be open - press Arrow Down to navigate await page.keyboard.press('ArrowDown'); - + const focused = await getFocusedElement(page); - const focusedRole = await focused.evaluate(el => el?.getAttribute('role')); - + const focusedRole = await focused.evaluate((el) => el?.getAttribute('role')); + // Should have focused on a menu item expect( focusedRole === 'menuitem' || focusedRole === 'option', @@ -496,7 +524,7 @@ test.describe('Site-wide Keyboard Navigation', () => { // Look for tab elements const tabs = await page.locator('[role="tab"]').all(); - + if (tabs.length < 2) { console.warn('⚠️ No tab interface found to test'); return; @@ -508,18 +536,15 @@ test.describe('Site-wide Keyboard Navigation', () => { // Get the aria-selected state const firstSelected = await tabs[0].getAttribute('aria-selected'); - + // Press ArrowRight to move to next tab await page.keyboard.press('ArrowRight'); await page.waitForTimeout(100); // Check if the second tab is now selected const secondSelected = await tabs[1].getAttribute('aria-selected'); - - expect( - secondSelected, - 'Arrow keys should switch between tabs' - ).toBe('true'); + + expect(secondSelected, 'Arrow keys should switch between tabs').toBe('true'); console.log('✅ Tab panels are keyboard navigable with arrow keys'); }); @@ -534,8 +559,8 @@ test.describe('Site-wide Keyboard Navigation', () => { for (let i = 0; i < maxTabs; i++) { await page.keyboard.press('Tab'); const focused = await getFocusedElement(page); - - const isVisible = await focused.evaluate(el => { + + const isVisible = await focused.evaluate((el) => { if (!el || el === document.body || el === document.documentElement) return true; const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); @@ -553,10 +578,7 @@ test.describe('Site-wide Keyboard Navigation', () => { } } - expect( - hiddenFocusCount, - 'Focus should never land on hidden elements' - ).toBe(0); + expect(hiddenFocusCount, 'Focus should never land on hidden elements').toBe(0); console.log('✅ Focus never lands on hidden elements'); }); @@ -566,10 +588,11 @@ test.describe('Site-wide Keyboard Navigation', () => { // Get all elements in DOM order const domOrder = await page.evaluate(() => { - const interactiveSelector = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const interactiveSelector = + 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'; const elements = Array.from(document.querySelectorAll(interactiveSelector)); return elements - .filter(el => { + .filter((el) => { const style = window.getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden'; }) @@ -583,12 +606,11 @@ test.describe('Site-wide Keyboard Navigation', () => { // Elements with tabindex="0" or no tabindex should generally appear in DOM order // Elements with tabindex > 0 are discouraged (anti-pattern) - const positiveTabindexElements = domOrder.filter(el => el.tabindex > 0); - - expect( - positiveTabindexElements.length, - 'Should avoid using tabindex > 0 (anti-pattern)' - ).toBe(0); + const positiveTabindexElements = domOrder.filter((el) => el.tabindex > 0); + + expect(positiveTabindexElements.length, 'Should avoid using tabindex > 0 (anti-pattern)').toBe( + 0 + ); console.log('✅ Tab order follows logical DOM order'); }); @@ -601,7 +623,7 @@ test.describe('Common Component Keyboard Support', () => { // Look for carousel controls const carousel = page.locator('[role="region"][aria-label*="carousel" i]').first(); - + if (!(await carousel.isVisible().catch(() => false))) { console.warn('⚠️ No carousel found to test'); return; @@ -609,17 +631,25 @@ test.describe('Common Component Keyboard Support', () => { // Look for next/previous buttons const nextButton = page.locator('button[aria-label*="next" i]').first(); - const prevButton = page.locator('button[aria-label*="previous" i], button[aria-label*="prev" i]').first(); + const prevButton = page + .locator('button[aria-label*="previous" i], button[aria-label*="prev" i]') + .first(); if (await nextButton.isVisible()) { await nextButton.focus(); await page.keyboard.press('Enter'); await page.waitForTimeout(300); - + console.log('✅ Carousel can be navigated with keyboard'); expect(true).toBe(true); } else { - console.warn('⚠️ Carousel found but no keyboard-accessible controls'); + // ArrowButton only renders when there are more items than fit at the + // current viewport width (scrollAndButtonsEnabled in the component). + // When all slides are visible, no navigation buttons are shown — + // this is correct, intentional behavior, not a missing-control bug. + console.log( + 'ℹ️ Carousel: all items visible at this viewport — navigation buttons correctly omitted' + ); } }); @@ -628,8 +658,10 @@ test.describe('Common Component Keyboard Support', () => { await page.goto('/', { waitUntil: 'networkidle' }); // Look for accordion elements - const accordionButtons = await page.locator('button[aria-expanded], [role="button"][aria-expanded]').all(); - + const accordionButtons = await page + .locator('button[aria-expanded], [role="button"][aria-expanded]') + .all(); + if (accordionButtons.length === 0) { console.warn('⚠️ No accordion found to test'); return; @@ -637,18 +669,15 @@ test.describe('Common Component Keyboard Support', () => { const button = accordionButtons[0]; const initialState = await button.getAttribute('aria-expanded'); - + // Toggle with keyboard await button.focus(); await page.keyboard.press('Enter'); await page.waitForTimeout(200); - + const newState = await button.getAttribute('aria-expanded'); - - expect( - newState !== initialState, - 'Accordion should toggle when Enter is pressed' - ).toBe(true); + + expect(newState !== initialState, 'Accordion should toggle when Enter is pressed').toBe(true); console.log('✅ Accordion is keyboard operable'); }); diff --git a/packages/launch-stackwright/CHANGELOG.md b/packages/launch-stackwright/CHANGELOG.md index 258612c2..e1ccb7fd 100644 --- a/packages/launch-stackwright/CHANGELOG.md +++ b/packages/launch-stackwright/CHANGELOG.md @@ -1,5 +1,14 @@ # launch-stackwright +## 0.2.6-alpha.0 + +### Patch Changes + +- Updated dependencies [669aeee] +- Updated dependencies [3819871] +- Updated dependencies [cd01671] + - @stackwright/cli@0.8.6-alpha.0 + ## 0.2.5 ### Patch Changes diff --git a/packages/launch-stackwright/package.json b/packages/launch-stackwright/package.json index 3ee4f425..576329ce 100644 --- a/packages/launch-stackwright/package.json +++ b/packages/launch-stackwright/package.json @@ -1,6 +1,6 @@ { "name": "launch-stackwright", - "version": "0.2.5", + "version": "0.2.6-alpha.0", "description": "Launch a new Stackwright project with the otter raft ready to build", "license": "MIT", "repository": { diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 2ba78c4a..682e4cbb 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,14 @@ # @stackwright/mcp +## 0.4.6-alpha.0 + +### Patch Changes + +- Updated dependencies [669aeee] +- Updated dependencies [3819871] +- Updated dependencies [cd01671] + - @stackwright/cli@0.8.6-alpha.0 + ## 0.4.5 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 905cd0e6..abe506dc 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@stackwright/mcp", - "version": "0.4.5", + "version": "0.4.6-alpha.0", "description": "MCP server for Stackwright — exposes content types, page management, and validation as agent tools", "license": "MIT", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e42e55cb..7653deac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,9 @@ importers: packages/core: dependencies: + '@radix-ui/react-accordion': + specifier: ^1.2.11 + version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stackwright/themes': specifier: workspace:* version: link:../themes