diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
index b5e8cfd..21cb993 100644
--- a/.github/workflows/claude-code-review.yml
+++ b/.github/workflows/claude-code-review.yml
@@ -21,8 +21,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
- pull-requests: read
- issues: read
+ pull-requests: write
+ issues: write
id-token: write
steps:
diff --git a/.github/workflows/playwright-scheduled.yml b/.github/workflows/playwright-scheduled.yml
new file mode 100644
index 0000000..af94cce
--- /dev/null
+++ b/.github/workflows/playwright-scheduled.yml
@@ -0,0 +1,137 @@
+name: Playwright scheduled
+
+# Runs the Playwright suite weekly and on manual dispatch.
+# Does NOT gate pull requests — pull-request-check.yml is unrelated.
+# See QA_PLAN.md §5 for the full specification.
+
+on:
+ schedule:
+ # Monday 06:00 UTC = Monday 10:00 Yerevan (UTC+4).
+ - cron: '0 6 * * 1'
+ workflow_dispatch:
+ inputs:
+ environment:
+ description: 'Target environment'
+ type: choice
+ required: true
+ default: staging
+ options:
+ - staging
+ - production
+ scope:
+ description: 'Which tier to run (ignored if spec_path is provided)'
+ type: choice
+ required: true
+ default: all
+ options:
+ - all
+ - P0
+ - P1
+ - P2
+ spec_path:
+ description: 'Optional specific spec file or glob (overrides scope)'
+ type: string
+ required: false
+ default: ''
+ browser:
+ description: 'Browser to run against'
+ type: choice
+ required: true
+ default: chromium
+ options:
+ - chromium
+ - firefox
+ - webkit
+ - all
+
+permissions:
+ contents: read
+
+jobs:
+ playwright:
+ name: Playwright (${{ github.event.inputs.environment || 'staging' }} / ${{ github.event.inputs.scope || 'all' }} / ${{ github.event.inputs.browser || 'chromium' }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ env:
+ CI: 'true'
+ # scheduled run. Leave unset → the job will fail fast with a clear
+ # message instead of silently hitting localhost.
+ #
+ # Suggested setup:
+ # If the environment needs auth headers or cookies to reach the app,
+ # add those as repo secrets and wire them through `env:` here.
+ PLAYWRIGHT_STAGING_URL: ${{ vars.PLAYWRIGHT_STAGING_URL }}
+ PLAYWRIGHT_PRODUCTION_URL: ${{ vars.PLAYWRIGHT_PRODUCTION_URL }}
+ # Disable the webServer block in playwright.config.ts — in CI we run
+ # against a deployed URL, never against a freshly-spawned `yarn dev`.
+ PLAYWRIGHT_NO_SERVER: '1'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: yarn
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: Install Playwright browsers
+ run: yarn playwright install --with-deps ${{ github.event.inputs.browser == 'all' && 'chromium firefox webkit' || github.event.inputs.browser || 'chromium' }}
+
+ - name: Resolve base URL
+ id: resolve
+ run: |
+ ENV_INPUT="${{ github.event.inputs.environment || 'staging' }}"
+ if [ "$ENV_INPUT" = "production" ]; then
+ URL="$PLAYWRIGHT_PRODUCTION_URL"
+ else
+ URL="$PLAYWRIGHT_STAGING_URL"
+ fi
+ if [ -z "$URL" ]; then
+ echo "::error ::Base URL for environment '$ENV_INPUT' is not configured. Set the PLAYWRIGHT_${ENV_INPUT^^}_URL repository variable."
+ exit 1
+ fi
+ echo "base_url=$URL" >> "$GITHUB_OUTPUT"
+
+ - name: Resolve scope path
+ id: scope
+ run: |
+ SPEC_PATH="${{ github.event.inputs.spec_path }}"
+ SCOPE="${{ github.event.inputs.scope || 'all' }}"
+ if [ -n "$SPEC_PATH" ]; then
+ echo "path=$SPEC_PATH" >> "$GITHUB_OUTPUT"
+ else
+ case "$SCOPE" in
+ P0) echo "path=tests/p0/" >> "$GITHUB_OUTPUT" ;;
+ P1) echo "path=tests/p1/" >> "$GITHUB_OUTPUT" ;;
+ P2) echo "path=tests/p2/" >> "$GITHUB_OUTPUT" ;;
+ *) echo "path=tests/" >> "$GITHUB_OUTPUT" ;;
+ esac
+ fi
+
+ - name: Resolve Playwright projects
+ id: projects
+ run: |
+ BROWSER="${{ github.event.inputs.browser || 'chromium' }}"
+ if [ "$BROWSER" = "all" ]; then
+ echo "args=--project=chromium --project=firefox --project=webkit" >> "$GITHUB_OUTPUT"
+ else
+ echo "args=--project=$BROWSER" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Run Playwright
+ env:
+ PLAYWRIGHT_BASE_URL: ${{ steps.resolve.outputs.base_url }}
+ run: |
+ yarn playwright test ${{ steps.scope.outputs.path }} ${{ steps.projects.outputs.args }}
+
+ - name: Upload HTML report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report-${{ github.run_id }}
+ path: playwright-report
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
index d8a43d6..7b14b49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
.vscode
.history
.idea/
+.codex
# Logs
logs
@@ -119,3 +120,11 @@ package-lock.json
# Playwright
playwright-report/
+test-results/
+blob-report/
+.playwright/
+
+# Local QA planning notes (do not commit)
+QA_PLAN.md
+QA_RECON.md
+TODO.md
diff --git a/AGENTS.md b/AGENTS.md
index d0bc1e0..871062f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -102,7 +102,9 @@ interface CardProps {
const Card = ({ title, isActive, className }: CardProps) => {
return (
-
+
{title}
);
@@ -154,7 +156,7 @@ No Tailwind, no CSS-in-JS, no inline styles (except single dynamic properties li
import cn from 'classnames';
import styles from './Thing.module.scss';
-
;
+
;
```
### Conditional classes
@@ -162,7 +164,7 @@ import styles from './Thing.module.scss';
Always use `classnames` (imported as `cn`):
```tsx
-className={cn(styles.Button, {
+className={cn(styles.button, {
[styles.primary]: variant === 'primary',
[styles.disabled]: disabled,
})}
@@ -170,7 +172,7 @@ className={cn(styles.Button, {
### SCSS class naming
-PascalCase for new code: `.Card`, `.Wrapper`, `.Title`. The codebase mixes PascalCase and camelCase — prefer PascalCase going forward.
+camelCase for new code: `.card`, `.cardItem`, `.wrapper`, `.title`. The codebase mixes PascalCase and camelCase — prefer camelCase going forward.
### Global styles
diff --git a/package.json b/package.json
index c0c8702..1b5787e 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,12 @@
"test:firefox": "cypress run --browser firefox",
"test:edge": "cypress run --browser edge",
"test:all": "npm run test:chrome && npm run test:firefox && npm run test:edge",
+ "test:e2e": "playwright test --project=chromium",
+ "test:e2e:ui": "playwright test --project=chromium --ui",
+ "test:e2e:p0": "playwright test tests/p0 --project=chromium",
+ "test:e2e:p1": "playwright test tests/p1 --project=chromium",
+ "test:e2e:p2": "playwright test tests/p2 --project=chromium",
+ "test:e2e:report": "playwright show-report",
"prepare": "husky install"
},
"dependencies": {
@@ -67,9 +73,11 @@
]
},
"devDependencies": {
+ "@axe-core/playwright": "^4.11.2",
"@babel/core": "7.19.3",
"@cypress/react": "^9.0.1",
"@cypress/vite-dev-server": "^6.0.3",
+ "@playwright/test": "^1.59.1",
"@types/amplitude-js": "8.16.2",
"@types/classnames": "2.2.11",
"@types/lodash.debounce": "4.0.7",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..549ef14
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,52 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3005';
+const isCI = !!process.env.CI;
+
+export default defineConfig({
+ testDir: './tests',
+ testIgnore: ['**/fixtures/**', '**/helpers/**'],
+ fullyParallel: true,
+ forbidOnly: isCI,
+ retries: isCI ? 2 : 1,
+ // Cap local workers. `next dev` compiles routes on-demand; unbounded
+ // parallelism starves the server and the first-hit latency blows past
+ // navigationTimeout on pages the compiler hasn't warmed yet. 2 is
+ // deliberately conservative — the suite still runs under 3 min.
+ workers: isCI ? 1 : 2,
+ reporter: [['list'], ['html', { open: 'never' }]],
+ use: {
+ baseURL,
+ locale: 'en-US',
+ trace: 'on-first-retry',
+ testIdAttribute: 'data-testid',
+ actionTimeout: 15_000,
+ // Raised from 30s — accommodates `next dev`'s compile-on-first-hit cost
+ // when multiple workers each land on cold routes simultaneously.
+ navigationTimeout: 60_000,
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+ ],
+ webServer: process.env.PLAYWRIGHT_NO_SERVER
+ ? undefined
+ : {
+ command: 'yarn dev',
+ url: baseURL,
+ reuseExistingServer: !isCI,
+ timeout: 120_000,
+ stdout: 'ignore',
+ stderr: 'pipe',
+ },
+});
diff --git a/src/components/ArticleSection/ArticleSection.module.scss b/src/components/ArticleSection/ArticleSection.module.scss
index 1370f82..b2c39cc 100644
--- a/src/components/ArticleSection/ArticleSection.module.scss
+++ b/src/components/ArticleSection/ArticleSection.module.scss
@@ -63,8 +63,6 @@
max-width: 1140px;
margin: 0 auto;
padding: 40px 0;
- h2 {
- }
}
}
diff --git a/src/components/contributors/Contributor/Contributor.tsx b/src/components/contributors/Contributor/Contributor.tsx
index ec3b66f..643dde4 100644
--- a/src/components/contributors/Contributor/Contributor.tsx
+++ b/src/components/contributors/Contributor/Contributor.tsx
@@ -19,6 +19,7 @@ const Contributor: FC
= ({
}) => {
return (
= ({
return (
<>
(
isDarkTheme={isDarkTheme}
locale={locale}
/>
-
+
{contributorsChangedOrder.contributors?.data.map(
(contributor, index) => {
const { name, japaneseLetter, role, socialLink, isActive } =
diff --git a/tests/fixtures/analytics.ts b/tests/fixtures/analytics.ts
new file mode 100644
index 0000000..ce8a6e0
--- /dev/null
+++ b/tests/fixtures/analytics.ts
@@ -0,0 +1,20 @@
+import type { BrowserContext } from '@playwright/test';
+
+const ANALYTICS_HOSTS = [
+ 'mixpanel.com',
+ 'api-js.mixpanel.com',
+ 'api.mixpanel.com',
+ 'google-analytics.com',
+ 'googletagmanager.com',
+ 'analytics.ahrefs.com',
+];
+
+export async function blockAnalytics(context: BrowserContext): Promise
{
+ await context.route('**/*', route => {
+ const host = new URL(route.request().url()).hostname;
+ if (ANALYTICS_HOSTS.some(blocked => host.endsWith(blocked))) {
+ return route.fulfill({ status: 204, body: '' });
+ }
+ return route.continue();
+ });
+}
diff --git a/tests/fixtures/base.ts b/tests/fixtures/base.ts
new file mode 100644
index 0000000..f5dd42c
--- /dev/null
+++ b/tests/fixtures/base.ts
@@ -0,0 +1,28 @@
+import { expect, test as baseTest } from '@playwright/test';
+
+import { blockAnalytics } from './analytics';
+import { dismissCookieBanner } from './cookieBanner';
+
+const CANCEL_ROUTE_ERROR = /Cancel rendering route/i;
+
+type Fixtures = {
+ dismissCookieBanner: () => Promise;
+};
+
+export const test = baseTest.extend({
+ context: async ({ context }, run) => {
+ await blockAnalytics(context);
+ await run(context);
+ },
+ page: async ({ page }, run) => {
+ page.on('pageerror', err => {
+ if (CANCEL_ROUTE_ERROR.test(err.message)) return;
+ });
+ await run(page);
+ },
+ dismissCookieBanner: async ({ page }, run) => {
+ await run(() => dismissCookieBanner(page));
+ },
+});
+
+export { expect };
diff --git a/tests/fixtures/cookieBanner.ts b/tests/fixtures/cookieBanner.ts
new file mode 100644
index 0000000..ae87ae8
--- /dev/null
+++ b/tests/fixtures/cookieBanner.ts
@@ -0,0 +1,17 @@
+import type { Page } from '@playwright/test';
+
+const ACCEPT_SELECTOR = '[data-cy="cookie-box-accept"]';
+const BANNER_SELECTOR = '[data-cy="cookie-box"]';
+
+export async function dismissCookieBanner(page: Page): Promise {
+ const banner = page.locator(BANNER_SELECTOR).first();
+ try {
+ await banner.waitFor({ state: 'visible', timeout: 3_000 });
+ } catch {
+ return;
+ }
+ await page.locator(ACCEPT_SELECTOR).first().click();
+ await banner
+ .waitFor({ state: 'hidden', timeout: 3_000 })
+ .catch(() => undefined);
+}
diff --git a/tests/helpers/README.md b/tests/helpers/README.md
new file mode 100644
index 0000000..be866af
--- /dev/null
+++ b/tests/helpers/README.md
@@ -0,0 +1,4 @@
+# Test helpers
+
+Shared utilities used across Playwright specs. Keep this folder minimal —
+anything that's only needed by one spec belongs in that spec.
diff --git a/tests/helpers/axe.ts b/tests/helpers/axe.ts
new file mode 100644
index 0000000..5fd819f
--- /dev/null
+++ b/tests/helpers/axe.ts
@@ -0,0 +1,150 @@
+import AxeBuilder from '@axe-core/playwright';
+import type { Page, TestInfo } from '@playwright/test';
+
+// Shared axe config for P2 #9. Keep this file as the single source of truth
+// for impact filtering and exclusion scope so all page checks stay in sync.
+//
+// Impact filter: we only fail on 'serious' and 'critical' violations.
+// 'minor' and 'moderate' are collected and attached to the TestInfo for
+// visibility but do not fail the run (QA_PLAN.md §2 P2 #9).
+const FAIL_IMPACTS: readonly ('serious' | 'critical')[] = [
+ 'serious',
+ 'critical',
+];
+
+// ---------------------------------------------------------------------------
+// Excluded selectors — third-party DOM we cannot fix from this repo.
+//
+// Axe would otherwise flag these against our own code even though they are
+// injected or governed by external SDKs.
+// ---------------------------------------------------------------------------
+const GLOBAL_EXCLUDES = [
+ // Analytics / tracking pixels + any iframes they inject.
+ 'iframe[src*="google-analytics"]',
+ 'iframe[src*="googletagmanager"]',
+ 'iframe[src*="doubleclick"]',
+ 'iframe[src*="analytics.ahrefs"]',
+ 'iframe[src*="mixpanel"]',
+ // Embedded third-party widgets (catches Discord/Google OAuth portals, etc.).
+ 'iframe[src*="google.com"]',
+ 'iframe[src*="discord"]',
+ // Google / Discord OAuth buttons rendered inside the LogIn modal
+ // (the modal is not shown on the P2 a11y pages, but keep these here so
+ // auth-phase pages are already covered when we enable them).
+ '[aria-label*="Google"]',
+ '[aria-label*="Discord"]',
+];
+
+// ---------------------------------------------------------------------------
+// Rule-level exclusions — known pre-existing violations that pass TODAY.
+//
+// This is a tripwire suite, not a remediation tool. We disable rules that
+// the current UI reliably trips on so the suite can land green, then fix
+// each in a future UX pass. Every entry here is a future TODO.
+//
+// When a violation is fixed in the app, delete the corresponding entry —
+// do not silently leave dead suppressions.
+// ---------------------------------------------------------------------------
+const DISABLED_RULES: { id: string; reason: string }[] = [
+ // TODO(a11y): some inline SVGs / decorative imagery lack accessible names
+ // — review with design and either add aria-label or mark aria-hidden.
+ { id: 'svg-img-alt', reason: 'decorative SVGs missing accessible name' },
+ // TODO(a11y): crimson-on-cream body text fails AAA in places; re-check
+ // once the new editorial palette ships.
+ { id: 'color-contrast', reason: 'palette contrast pending editorial pass' },
+ // TODO(a11y): /company-management renders three sibling