diff --git a/.github/workflows/site-ci.yml b/.github/workflows/site-ci.yml index a0f143e..8a181a9 100644 --- a/.github/workflows/site-ci.yml +++ b/.github/workflows/site-ci.yml @@ -48,3 +48,15 @@ jobs: - name: Browser smoke tests run: npm run browser:test + + - name: Lighthouse thresholds + run: npm run lighthouse + + - name: Upload Lighthouse reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: lighthouse-reports + path: site/.lighthouseci + if-no-files-found: ignore + include-hidden-files: true diff --git a/site/.gitignore b/site/.gitignore index 9bbb224..2541414 100644 --- a/site/.gitignore +++ b/site/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ playwright-report/ test-results/ +output/ +.lighthouseci/ diff --git a/site/index.html b/site/index.html index fcad1b8..6a77292 100644 --- a/site/index.html +++ b/site/index.html @@ -5,6 +5,7 @@ + Backline | API regression history diff --git a/site/lighthouserc.cjs b/site/lighthouserc.cjs new file mode 100644 index 0000000..2b78dba --- /dev/null +++ b/site/lighthouserc.cjs @@ -0,0 +1,28 @@ +/* global module */ + +module.exports = { + ci: { + collect: { + startServerCommand: 'npm run preview -- --host 127.0.0.1', + startServerReadyPattern: 'Local', + startServerReadyTimeout: 30_000, + url: ['http://127.0.0.1:4173/'], + numberOfRuns: 3, + settings: { + preset: 'desktop', + }, + }, + assert: { + assertions: { + 'categories:performance': ['error', { minScore: 0.95 }], + 'categories:accessibility': ['error', { minScore: 1 }], + 'categories:best-practices': ['error', { minScore: 0.95 }], + 'categories:seo': ['error', { minScore: 0.95 }], + }, + }, + upload: { + target: 'filesystem', + outputDir: '.lighthouseci', + }, + }, +}; diff --git a/site/package.json b/site/package.json index 0d3bdb1..a60cbc5 100644 --- a/site/package.json +++ b/site/package.json @@ -10,7 +10,8 @@ "typecheck": "tsc --noEmit", "lint": "eslint .", "test": "node --test tests/content.test.mjs", - "browser:test": "playwright test" + "browser:test": "playwright test", + "lighthouse": "lhci autorun" }, "devDependencies": { "@axe-core/playwright": "^4.12.1", diff --git a/site/playwright.config.ts b/site/playwright.config.ts index 994e9be..8a6e426 100644 --- a/site/playwright.config.ts +++ b/site/playwright.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ timeout: 30_000, fullyParallel: true, reporter: 'line', + snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}', use: { baseURL: 'http://127.0.0.1:4173', trace: 'retain-on-failure', diff --git a/site/public/robots.txt b/site/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/site/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/site/src/style.css b/site/src/style.css index a437c2d..91ba077 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -180,9 +180,9 @@ p { color: var(--muted); line-height: 1.65; } .section { padding-top: 7.5rem; padding-bottom: 7.5rem; } -.section-intro { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 0.5fr); gap: 4rem; align-items: end; margin-bottom: 2.5rem; } +.section-intro { max-width: 760px; margin-bottom: 2.5rem; } -.section-intro p:not(.eyebrow) { max-width: 390px; margin-bottom: 0.25rem; font-size: 0.95rem; } +.section-intro p:not(.eyebrow) { max-width: 560px; margin-bottom: 0.25rem; font-size: 0.95rem; } .report-capture { width: 100%; box-shadow: 22px 24px 0 rgba(0, 0, 0, 0.18); } @@ -279,11 +279,10 @@ html[data-motion="enabled"] .reveal { transform: translateY(16px); transition: t html[data-motion="enabled"] .reveal.is-visible { transform: translateY(0); } @media (max-width: 900px) { - .hero, .section-intro, .policy-section, .architecture-layout, .boundaries-section, .quick-start { grid-template-columns: 1fr; } + .hero, .policy-section, .architecture-layout, .boundaries-section, .quick-start { grid-template-columns: 1fr; } .hero { gap: 3.5rem; min-height: auto; } .hero-copy { max-width: 720px; } .hero-capture { justify-self: start; } - .section-intro { gap: 1.25rem; } .section-intro p:not(.eyebrow) { max-width: 560px; } .boundaries-section { gap: 2.8rem; } .architecture-chain { justify-content: flex-start; } diff --git a/site/tests/__screenshots__/landing-desktop.png b/site/tests/__screenshots__/landing-desktop.png new file mode 100644 index 0000000..f95f451 Binary files /dev/null and b/site/tests/__screenshots__/landing-desktop.png differ diff --git a/site/tests/__screenshots__/landing-mobile.png b/site/tests/__screenshots__/landing-mobile.png new file mode 100644 index 0000000..7f80b0e Binary files /dev/null and b/site/tests/__screenshots__/landing-mobile.png differ diff --git a/site/tests/accessibility.spec.ts b/site/tests/accessibility.spec.ts index ee9d4ab..533d756 100644 --- a/site/tests/accessibility.spec.ts +++ b/site/tests/accessibility.spec.ts @@ -8,5 +8,15 @@ test('has no accessibility violations in the configured WCAG checks', async ({ p .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) .analyze(); + const requiredRules = new Set([ + 'aria-command-name', + 'button-name', + 'color-contrast', + 'focus-order-semantics', + 'link-name', + 'scrollable-region-focusable', + ]); + + expect(results.violations.filter((violation) => requiredRules.has(violation.id))).toEqual([]); expect(results.violations).toEqual([]); }); diff --git a/site/tests/content.test.mjs b/site/tests/content.test.mjs index 4235328..c2cd34a 100644 --- a/site/tests/content.test.mjs +++ b/site/tests/content.test.mjs @@ -2,9 +2,10 @@ import assert from 'node:assert/strict'; import { readFile, stat } from 'node:fs/promises'; import test from 'node:test'; -const [html, css, terminalCapture, reportCapture] = await Promise.all([ +const [html, css, robots, terminalCapture, reportCapture] = await Promise.all([ readFile(new URL('../index.html', import.meta.url), 'utf8'), readFile(new URL('../src/style.css', import.meta.url), 'utf8'), + readFile(new URL('../public/robots.txt', import.meta.url), 'utf8'), stat(new URL('../public/demo/failed-run-diff.webp', import.meta.url)), stat(new URL('../public/demo/markdown-report.webp', import.meta.url)), ]); @@ -30,6 +31,7 @@ test('landing page includes accessible structure and repository links', () => { 'aria-label="Primary navigation"', 'aria-label="backline - home"', 'aria-controls="site-nav"', + 'rel="canonical" href="https://backline-site-xi.vercel.app/"', 'https://github.com/ChimdumebiNebolisa/Backline', ]) { assert.ok(html.includes(phrase), `expected page to contain ${phrase}`); @@ -52,3 +54,7 @@ test('landing page uses authentic demo captures and a direct setup path', () => test('visible copy avoids typographic dash clutter', () => { assert.doesNotMatch(html, /[\u2014\u2013]/); }); + +test('crawler policy allows the landing page to be indexed', () => { + assert.equal(robots, 'User-agent: *\nAllow: /\n'); +}); diff --git a/site/tests/site.spec.ts b/site/tests/site.spec.ts index fea3760..1560dfc 100644 --- a/site/tests/site.spec.ts +++ b/site/tests/site.spec.ts @@ -5,7 +5,14 @@ test('renders the landing page and primary paths on desktop', async ({ page }) = await expect(page).toHaveTitle('Backline | API regression history'); await expect(page.getByRole('heading', { name: 'Regression history for APIs that change.' })).toBeVisible(); - await expect(page.getByRole('img', { name: /failed broken-endpoint run and its diff/i })).toBeVisible(); + const terminalCapture = page.getByRole('img', { name: /failed broken-endpoint run and its diff/i }); + const reportCapture = page.getByRole('img', { name: /two passed and one failed check/i }); + + await expect(terminalCapture).toBeVisible(); + await expect(terminalCapture).toHaveAttribute('src', '/demo/failed-run-diff.webp'); + await expect(reportCapture).toHaveAttribute('src', '/demo/markdown-report.webp'); + expect(await terminalCapture.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true); + expect(await reportCapture.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true); await expect(page.locator('[aria-label="Start locally"]')).toContainText('docker compose up --build -d'); await expect(page.getByRole('link', { name: /Open setup guide/ })).toHaveAttribute( 'href', @@ -17,6 +24,17 @@ test('renders the landing page and primary paths on desktop', async ({ page }) = ); }); +test('keeps normal-motion reveal content visible before it settles', async ({ page }) => { + await page.goto('/'); + + const opacities = await page.locator('[data-reveal]').evaluateAll((elements) => + elements.map((element) => getComputedStyle(element).opacity), + ); + + expect(opacities).toContain('1'); + expect(opacities.every((opacity) => opacity === '1')).toBe(true); +}); + test('supports mobile navigation and returns focus after Escape', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); @@ -34,10 +52,55 @@ test('supports mobile navigation and returns focus after Escape', async ({ page await expect(toggle).toBeFocused(); }); +test('does not overflow horizontally at the mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); +}); + test('honors reduced-motion preference', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/'); const scrollBehavior = await page.evaluate(() => getComputedStyle(document.documentElement).scrollBehavior); expect(scrollBehavior).toBe('auto'); + + const revealState = await page.locator('[data-reveal]').evaluateAll((elements) => + elements.map((element) => { + const style = getComputedStyle(element); + return { opacity: style.opacity, transform: style.transform }; + }), + ); + expect(revealState).not.toHaveLength(0); + expect(revealState.every((state) => state.opacity === '1' && state.transform === 'none')).toBe(true); +}); + +test('matches the desktop visual baseline', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto('/'); + + await expect(page).toHaveScreenshot('landing-desktop.png', { + animations: 'disabled', + caret: 'hide', + maxDiffPixelRatio: 0.05, + }); +}); + +test('matches the mobile visual baseline', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + + await expect(page).toHaveScreenshot('landing-mobile.png', { + animations: 'disabled', + caret: 'hide', + maxDiffPixelRatio: 0.05, + }); });