diff --git a/ui/e2e/a11y.spec.ts b/ui/e2e/a11y.spec.ts new file mode 100644 index 0000000..8c92381 --- /dev/null +++ b/ui/e2e/a11y.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "./fixtures"; +import AxeBuilder from "@axe-core/playwright"; + +const ROUTES = ["/", "/notes", "/docs", "/graph"]; + +test.describe("axe a11y audit — zero violations", () => { + for (const url of ROUTES) { + test(`no violations on ${url}`, async ({ stubbedPage: page }) => { + await page.goto(url); + await page.locator("main#main").waitFor(); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa"]) + .analyze(); + expect( + results.violations, + `${url}:\n${JSON.stringify(results.violations, null, 2)}`, + ).toEqual([]); + }); + } + + test("no violations on /mcp (client-side nav)", async ({ stubbedPage: page }) => { + // /mcp is proxied in dev-server, navigate via SPA history. + await page.goto("/"); + await page.locator("main#main").waitFor(); + await page.evaluate(() => window.history.pushState({}, "", "/mcp")); + await page.evaluate(() => window.dispatchEvent(new PopStateEvent("popstate"))); + await page.waitForTimeout(200); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa"]) + .analyze(); + expect( + results.violations, + `/mcp:\n${JSON.stringify(results.violations, null, 2)}`, + ).toEqual([]); + }); +}); diff --git a/ui/e2e/focus.spec.ts b/ui/e2e/focus.spec.ts new file mode 100644 index 0000000..4f9e747 --- /dev/null +++ b/ui/e2e/focus.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "./fixtures"; + +test.describe("focus management", () => { + test("skip-link is the first tab target and moves focus to main#main", async ({ stubbedPage: page }) => { + await page.goto("/"); + await page.locator("main#main").waitFor(); + await page.keyboard.press("Tab"); + const focusedIsSkipLink = await page.evaluate( + () => document.activeElement?.textContent?.toLowerCase().includes("skip to main content") ?? false, + ); + expect(focusedIsSkipLink).toBe(true); + await page.keyboard.press("Enter"); + const mainFocused = await page.evaluate(() => document.activeElement?.id === "main"); + expect(mainFocused).toBe(true); + }); + + test("command palette returns focus to the invoking button on close", async ({ stubbedPage: page }) => { + await page.goto("/"); + await page.locator("main#main").waitFor(); + const searchBtn = page.locator(".site-header-search").first(); + await searchBtn.focus(); + await expect(searchBtn).toBeFocused(); + await page.keyboard.press("Enter"); + await page.getByPlaceholder(/search notes, docs, entities/i).waitFor(); + await page.keyboard.press("Escape"); + // After close, focus must return to the search button. + await expect(searchBtn).toBeFocused(); + }); + + test("dialog traps focus while open (radix)", async ({ stubbedPage: page }) => { + await page.goto("/"); + await page.locator("main#main").waitFor(); + await page.keyboard.press("ControlOrMeta+k"); + await page.getByPlaceholder(/search notes, docs, entities/i).waitFor(); + // Tab 20 times; focus must stay inside the palette dialog. + for (let i = 0; i < 20; i++) { + await page.keyboard.press("Tab"); + const inside = await page.evaluate(() => + Boolean(document.activeElement?.closest("[role=\"dialog\"]")), + ); + expect(inside, `focus escaped the dialog on Tab ${i}`).toBe(true); + } + }); +}); diff --git a/ui/e2e/mobile.spec.ts b/ui/e2e/mobile.spec.ts new file mode 100644 index 0000000..66fb5fc --- /dev/null +++ b/ui/e2e/mobile.spec.ts @@ -0,0 +1,55 @@ +import { test as fixtureTest, expect } from "./fixtures"; + +const mobileTest = fixtureTest.extend({}); +mobileTest.use({ viewport: { width: 375, height: 812 } }); + +mobileTest.describe("mobile 375px viewport", () => { + mobileTest("sidebar collapses at 375px", async ({ stubbedPage: page }) => { + await page.goto("/"); + await page.locator("main#main").waitFor(); + // The shadcn sidebar hides on mobile by default; assert it is not part of + // the initial visible tab-order. + const collapsedOrHidden = await page.evaluate(() => { + const sb = document.querySelector("[data-slot='sidebar'], [data-sidebar='sidebar']"); + if (!sb) return true; // treated as collapsed if not rendered + const r = sb.getBoundingClientRect(); + return r.width < 60 || r.left < -10 || getComputedStyle(sb).display === "none"; + }); + expect(collapsedOrHidden).toBe(true); + }); + + mobileTest("header buttons meet 44x44 tap-target minimum", async ({ stubbedPage: page }) => { + await page.goto("/"); + await page.locator("main#main").waitFor(); + const buttons = page.locator(".site-header button, .site-header a[role='button']"); + const count = await buttons.count(); + for (let i = 0; i < count; i++) { + const box = await buttons.nth(i).boundingBox(); + if (!box) continue; // button hidden on this viewport + expect.soft(box.width, `button ${i} too narrow`).toBeGreaterThanOrEqual(44); + expect.soft(box.height, `button ${i} too short`).toBeGreaterThanOrEqual(44); + } + }); + + mobileTest("command palette fills the viewport", async ({ stubbedPage: page }) => { + await page.goto("/"); + await page.locator("main#main").waitFor(); + await page.keyboard.press("ControlOrMeta+k"); + const dialog = page.locator("[role='dialog']").first(); + await dialog.waitFor(); + const box = await dialog.boundingBox(); + expect(box?.width ?? 0).toBeGreaterThanOrEqual(320); + }); + + mobileTest("documents list does not overflow horizontally", async ({ stubbedPage: page }) => { + await page.goto("/docs"); + await page.locator("main#main").waitFor(); + // Detect horizontal scroll on
— tables should scroll inside their + // wrapper, not push the body. + const bodyOverflow = await page.evaluate(() => { + const d = document.documentElement; + return d.scrollWidth - d.clientWidth; + }); + expect(bodyOverflow).toBeLessThanOrEqual(1); + }); +}); diff --git a/ui/e2e/no-console-errors.spec.ts b/ui/e2e/no-console-errors.spec.ts new file mode 100644 index 0000000..d5b2016 --- /dev/null +++ b/ui/e2e/no-console-errors.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from "./fixtures"; + +// All Block 5 primary routes. `/mcp` is navigated via client-side routing +// from `/` because the Vite dev-server has a proxy rule for `/mcp` that +// would otherwise intercept the document request. +const ROUTES = ["/", "/notes", "/docs", "/graph"]; + +test.describe("no update-depth warnings on any route", () => { + for (const url of ROUTES) { + test(`no update-depth warning on ${url}`, async ({ stubbedPage: page }) => { + const errors: string[] = []; + const warnings: string[] = []; + page.on("console", (msg) => { + const text = msg.text(); + if (msg.type() === "error") errors.push(text); + if (msg.type() === "warning") warnings.push(text); + }); + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto(url); + await page.locator("main#main").waitFor(); + // Give effects a tick to run — if there's an infinite loop, it fires + // within a few microtasks and the warning lands here. + await page.waitForTimeout(500); + + const offending = [...errors, ...warnings].filter((t) => + /maximum update depth|too many re-renders/i.test(t), + ); + expect(offending, `${url} emitted: ${offending.join(" | ")}`).toEqual([]); + }); + } + + test("no update-depth warning on /mcp (client-side nav)", async ({ stubbedPage: page }) => { + const errors: string[] = []; + const warnings: string[] = []; + page.on("console", (msg) => { + const text = msg.text(); + if (msg.type() === "error") errors.push(text); + if (msg.type() === "warning") warnings.push(text); + }); + page.on("pageerror", (err) => errors.push(err.message)); + + // Start at Home, then navigate to /mcp via SPA history — this avoids the + // Vite dev-server /mcp proxy rule that otherwise serves a 404 from the + // backend port when a document request is issued directly. + await page.goto("/"); + await page.locator("main#main").waitFor(); + await page.evaluate(() => window.history.pushState({}, "", "/mcp")); + await page.evaluate(() => window.dispatchEvent(new PopStateEvent("popstate"))); + await page.waitForTimeout(500); + + const offending = [...errors, ...warnings].filter((t) => + /maximum update depth|too many re-renders/i.test(t), + ); + expect(offending, `/mcp emitted: ${offending.join(" | ")}`).toEqual([]); + }); +}); diff --git a/ui/e2e/safe-area.spec.ts b/ui/e2e/safe-area.spec.ts new file mode 100644 index 0000000..98e2cc7 --- /dev/null +++ b/ui/e2e/safe-area.spec.ts @@ -0,0 +1,24 @@ +import { test as fixtureTest, expect } from "./fixtures"; + +// Simulate an iPhone 14 viewport on the configured Chromium project rather +// than using devices["iPhone 14"] (which pins the webkit engine). The +// browser engine is not what this test exercises — the CSS rule is. The +// 390x844 dimensions match the iPhone 14 logical viewport. +fixtureTest.use({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, +}); + +fixtureTest("header padding accommodates safe-area-inset-top on iPhone 14 viewport", async ({ stubbedPage: page }) => { + await page.goto("/"); + await page.locator("main#main").waitFor(); + const header = page.locator(".site-header").first(); + await expect(header).toBeVisible(); + const paddingTopPx = await header.evaluate( + (el) => parseFloat(getComputedStyle(el).paddingTop), + ); + // max(1rem, env(safe-area-inset-top)) must be at least 1rem = 16px. + expect(paddingTopPx).toBeGreaterThanOrEqual(16); +}); diff --git a/ui/e2e/theme-flash.spec.ts b/ui/e2e/theme-flash.spec.ts new file mode 100644 index 0000000..6eb1ebe --- /dev/null +++ b/ui/e2e/theme-flash.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "./fixtures"; + +test.describe("theme-flash", () => { + test("dark theme is applied before React hydrates", async ({ stubbedPage: page }) => { + // Seed localStorage BEFORE navigating so the inline script can read it. + await page.addInitScript(() => { + window.localStorage.setItem( + "docsiq-ui", + JSON.stringify({ state: { theme: "dark" }, version: 0 }), + ); + }); + await page.goto("/"); + // Check the html element's class list at the earliest opportunity. + const hasDark = await page.evaluate(() => + document.documentElement.classList.contains("dark"), + ); + expect(hasDark).toBe(true); + // And data-theme is set too + const themeAttr = await page.evaluate(() => + document.documentElement.dataset.theme, + ); + expect(themeAttr).toBe("dark"); + }); + + test("light theme renders without .dark class", async ({ stubbedPage: page }) => { + await page.addInitScript(() => { + window.localStorage.setItem( + "docsiq-ui", + JSON.stringify({ state: { theme: "light" }, version: 0 }), + ); + }); + await page.goto("/"); + const hasDark = await page.evaluate(() => + document.documentElement.classList.contains("dark"), + ); + expect(hasDark).toBe(false); + }); + + test("system theme resolves via prefers-color-scheme before hydration", async ({ browser }) => { + const ctx = await browser.newContext({ colorScheme: "dark" }); + const p2 = await ctx.newPage(); + await p2.addInitScript(() => { + window.localStorage.setItem( + "docsiq-ui", + JSON.stringify({ state: { theme: "system" }, version: 0 }), + ); + }); + await p2.goto("/"); + const hasDark = await p2.evaluate(() => + document.documentElement.classList.contains("dark"), + ); + expect(hasDark).toBe(true); + await ctx.close(); + }); +}); diff --git a/ui/index.html b/ui/index.html index e7c5e5b..158abc8 100644 --- a/ui/index.html +++ b/ui/index.html @@ -11,6 +11,37 @@