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