Skip to content
Open
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
32 changes: 32 additions & 0 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,36 @@ test.describe("Accessibility", () => {
await expect(skipLink).toHaveCount(1);
await expect(skipLink).toContainText("Skip to repositories");
});

test("primary header and hero touch targets clear WCAG 2.5.5 (≥44×44 CSS px)", async ({
page,
}) => {
// Regression for the 2026-04-18 critique finding: the four primary
// header/hero controls were ≤40px tall (109×40, 161×30, 161×36,
// 185×20). WCAG 2.5.5 Level AAA recommends 44×44; `min-h-11` gets us
// there without changing the visual weight of the Button component.
await page.goto("/");
const header = page.locator("header").first();
const controls = [
{ name: "ethstar logo link", loc: header.locator('a[href="/"]').first() },
{
name: "Propose more repos link",
loc: header.getByRole("link", { name: /propose/i }).first(),
},
{
name: "Sign in with GitHub button",
loc: header.getByRole("button", { name: /sign in/i }).first(),
},
{
name: "Browse the repositories button",
loc: page.getByRole("button", { name: /browse the repositories/i }),
},
];
for (const { name, loc } of controls) {
await expect(loc).toBeVisible();
const box = await loc.boundingBox();
if (!box) throw new Error(`missing bounding box for ${name}`);
expect(box.height, `${name} height`).toBeGreaterThanOrEqual(44);
}
});
});
133 changes: 133 additions & 0 deletions frontend/e2e/command-palette.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright © 2026 Miguel Tenorio Potrony - AntiD2ta.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { test, expect, type Page } from "@playwright/test";
import { seedConsent } from "./helpers";

// Stub window.open before any page script runs, and capture invocations on
// the window so the test can read them back via evaluate.
async function stubWindowOpen(page: Page): Promise<void> {
await page.addInitScript(() => {
const w = window as Window & {
__openedUrls?: string[];
open: typeof window.open;
};
w.__openedUrls = [];
w.open = (url?: string | URL) => {
w.__openedUrls!.push(typeof url === "string" ? url : String(url ?? ""));
return null;
};
});
}

async function getOpenedUrls(page: Page): Promise<string[]> {
return page.evaluate(
() =>
(window as Window & { __openedUrls?: string[] }).__openedUrls ?? [],
);
}

// Keyboard shortcuts only work once React has hydrated and the window-level
// ⌘K listener is attached — tests wait for this anchor before pressing keys.
async function waitForHome(page: Page): Promise<void> {
await expect(page.getByTestId("command-palette-trigger")).toBeVisible();
}
async function waitForPrivacy(page: Page): Promise<void> {
await expect(
page.getByRole("heading", { name: /privacy policy/i }),
).toBeVisible();
}

test.use({ reducedMotion: "reduce" });

test.describe("Command palette — global ⌘K trigger", () => {
test.beforeEach(async ({ page }) => {
await seedConsent(page);
await stubWindowOpen(page);
});

test("⌘K opens the palette from the home page", async ({ page }) => {
await page.goto("/");
await waitForHome(page);
await page.keyboard.press("ControlOrMeta+k");
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
const input = dialog.getByPlaceholder(
/search routes, actions, or repositories/i,
);
await expect(input).toBeVisible();
await expect(input).toBeFocused();
});

test("Esc closes the palette", async ({ page }) => {
await page.goto("/");
await waitForHome(page);
await page.keyboard.press("ControlOrMeta+k");
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
});

test("clicking the header trigger opens the palette", async ({ page }) => {
await page.goto("/");
await page.getByTestId("command-palette-trigger").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
});

test("typing 'go-ethereum' narrows the results and Enter opens the repo URL", async ({
page,
}) => {
await page.goto("/");
await waitForHome(page);
await page.keyboard.press("ControlOrMeta+k");
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.keyboard.type("go-ethereum");
await expect(
dialog.getByRole("option", { name: /ethereum\/go-ethereum/i }),
).toBeVisible();
await expect(
dialog.getByRole("option", { name: /ethereum\/solidity/i }),
).toHaveCount(0);
// Enter activates the first match — assert window.open was stubbed with
// the right URL, without actually opening a new tab.
await page.keyboard.press("Enter");
await expect(dialog).not.toBeVisible();
const opened = await getOpenedUrls(page);
expect(opened).toContain("https://github.com/ethereum/go-ethereum");
});

test("selecting 'Home' from /privacy navigates back to /", async ({
page,
}) => {
await page.goto("/privacy");
await expect(page).toHaveURL(/\/privacy$/);
await waitForPrivacy(page);
await page.keyboard.press("ControlOrMeta+k");
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("option", { name: /^home$/i }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/$/);
});

test("⌘K opens the palette from the /privacy route too", async ({ page }) => {
await page.goto("/privacy");
await waitForPrivacy(page);
await page.keyboard.press("ControlOrMeta+k");
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
});
});
46 changes: 46 additions & 0 deletions frontend/e2e/saturn-nav.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,52 @@ test.describe("Saturn ring navigation — interactions", () => {
expect(nextLabel).not.toBe(firstLabel);
});

test("Enter on a focused chip jumps to the matching marquee card", async ({
page,
}) => {
// Regression for the critique's "power-user efficiency gap" finding:
// the chip's keyboard handler must map Enter → onJump, not rely on the
// default browser activation (which would follow the href to GitHub
// instead of scrolling to the marquee card).
await page.goto("/");
const ring = page.getByRole("region", {
name: /saturn repository navigator/i,
});
await expect(ring).toBeVisible();
// Focus the go-ethereum chip directly so the test doesn't depend on the
// default tabbable chip (which is ring-specific).
await page.evaluate(() => {
const chip = document.querySelector<HTMLAnchorElement>(
'a[aria-label="ethereum/go-ethereum, not starred"]',
);
if (!chip) throw new Error("chip not found");
chip.focus();
});
await page.keyboard.press("Enter");
const card = page.locator(
'article[data-repo-key="ethereum/go-ethereum"]',
).first();
await expect(card).toHaveClass(/repo-card-highlight/, { timeout: 1500 });
});

test("keyboard hint reveals when a chip gains focus", async ({ page }) => {
// Discoverability regression: the Saturn section must surface its
// keyboard shortcuts when a keyboard user tabs in. The hint is
// `opacity: 0` at rest and `opacity: 1` once the section matches
// `:focus-within`.
await page.goto("/");
const hint = page.getByTestId("saturn-keyboard-hint");
await expect(hint).toHaveCSS("opacity", "0");
await page.evaluate(() => {
const chip = document.querySelector<HTMLAnchorElement>(
'a[aria-label="ethereum/go-ethereum, not starred"]',
);
if (!chip) throw new Error("chip not found");
chip.focus();
});
await expect(hint).toHaveCSS("opacity", "1");
});

test("right-click / middle-click fallback: chip still carries href", async ({
page,
}) => {
Expand Down
23 changes: 18 additions & 5 deletions frontend/src/components/auth-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { ListPlus, LogOut } from "lucide-react";
import { ListPlus, LogOut, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import type { GitHubUser } from "@/lib/types";
import { MAINTAINERS_URL } from "@/lib/constants";
import { useCommandPalette } from "@/hooks/command-palette-context";

interface AuthHeaderProps {
user: GitHubUser | null;
Expand All @@ -33,12 +34,13 @@ export function AuthHeader({
onLogin,
onLogout,
}: AuthHeaderProps) {
const { open: openCommandPalette } = useCommandPalette();
return (
<header className="glass sticky top-0 z-50 flex items-center justify-between gap-2 px-4 py-3 sm:gap-3 sm:px-6">
<nav aria-label="Site">
<a
href="/"
className="flex items-center gap-2 font-heading text-lg font-bold tracking-tight"
className="inline-flex min-h-11 items-center gap-2 font-heading text-lg font-bold tracking-tight"
>
<img
src="/logo-128.png"
Expand All @@ -57,13 +59,24 @@ export function AuthHeader({
href={MAINTAINERS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<ListPlus className="size-3.5" aria-hidden="true" />
<span className="max-sm:hidden">Propose more repos</span>
<span className="sm:hidden">Propose</span>
</a>

<button
type="button"
onClick={openCommandPalette}
aria-label="Open command palette"
data-testid="command-palette-trigger"
className="inline-flex min-h-11 min-w-11 items-center gap-1.5 rounded-full border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground max-sm:justify-center max-sm:px-0"
>
<Search className="size-3.5" aria-hidden="true" />
<kbd className="font-mono max-sm:hidden">⌘K</kbd>
</button>

{isLoading ? (
<div className="flex items-center gap-2 sm:gap-3" role="status" aria-label="Loading account">
<Skeleton className="size-6 rounded-full" />
Expand All @@ -84,7 +97,7 @@ export function AuthHeader({
variant="ghost"
size="sm"
onClick={onLogout}
className="rounded-full"
className="min-h-11 min-w-11 rounded-full"
aria-label="Sign out"
>
<LogOut aria-hidden="true" />
Expand All @@ -93,7 +106,7 @@ export function AuthHeader({
) : (
<Button
onClick={onLogin}
className="rounded-full bg-primary text-primary-foreground hover:bg-primary/90"
className="min-h-11 rounded-full bg-primary text-primary-foreground hover:bg-primary/90"
>
<span className="max-sm:hidden">Sign in with GitHub</span>
<span className="sm:hidden">Sign in</span>
Expand Down
Loading
Loading