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,
+ });
});