Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions ui/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
44 changes: 44 additions & 0 deletions ui/e2e/focus.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
55 changes: 55 additions & 0 deletions ui/e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <body> — 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);
});
});
57 changes: 57 additions & 0 deletions ui/e2e/no-console-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
24 changes: 24 additions & 0 deletions ui/e2e/safe-area.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
55 changes: 55 additions & 0 deletions ui/e2e/theme-flash.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
31 changes: 31 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>docsiq — GraphRAG knowledge base</title>
<script>
// Block 5.9 — theme-flash guard. Applies the persisted theme class
// before React hydrates so there is no FOUC on first paint. Must run
// synchronously in <head>. Keep in sync with Zustand persist key
// `docsiq-ui` and the Providers.tsx effect that toggles .dark.
(function () {
try {
var raw = localStorage.getItem("docsiq-ui");
var theme = "system";
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && parsed.state && typeof parsed.state.theme === "string") {
theme = parsed.state.theme;
}
}
var effective = theme;
if (theme === "system") {
effective = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
var root = document.documentElement;
root.dataset.theme = effective;
if (effective === "dark") root.classList.add("dark");
} catch (e) {
// If localStorage is unavailable (privacy mode) we simply let
// React decide after hydration; there is a brief FOUC but no
// crash. Do not log — this runs before any logger is attached.
}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
14 changes: 14 additions & 0 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"zustand": "^5.0.2"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.2",
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.9.1",
Expand Down
Loading
Loading