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
71 changes: 71 additions & 0 deletions frontend/e2e/home-mobile-defi-split.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// 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 } from "@playwright/test";
import { seedConsent } from "./helpers";

test.beforeEach(async ({ page }) => {
await seedConsent(page);
});

test.describe("DeFi category mobile row split", () => {
test("mobile viewport renders two DeFi rows with a grouping wrapper", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");

const defiRows = page.getByRole("region", {
name: /^Scrolling list of DeFi & Smart Contracts repositories, row [12] of 2$/,
});
await expect(defiRows).toHaveCount(2);

// The first row is labelled "row 1 of 2" and the second "row 2 of 2" —
// ordered top-to-bottom.
await expect(
page.getByRole("region", {
name: "Scrolling list of DeFi & Smart Contracts repositories, row 1 of 2",
}),
).toBeVisible();
await expect(
page.getByRole("region", {
name: "Scrolling list of DeFi & Smart Contracts repositories, row 2 of 2",
}),
).toBeVisible();

// Both rows sit inside a logical group so screen readers can still treat
// DeFi as a single unit.
const group = page.getByRole("group", {
name: "DeFi & Smart Contracts repositories",
});
await expect(group).toBeVisible();
await expect(group.getByRole("region")).toHaveCount(2);
});

test("desktop viewport keeps a single DeFi row", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");

// Desktop path: no row-N-of-2 labels, just the unqualified label.
await expect(
page.getByRole("region", {
name: "Scrolling list of DeFi & Smart Contracts repositories",
}),
).toHaveCount(1);
await expect(
page.getByRole("region", {
name: /row 1 of 2/,
}),
).toHaveCount(0);
});
});
136 changes: 136 additions & 0 deletions frontend/e2e/repo-card-clickable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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 } from "@playwright/test";
import { seedConsent } from "./helpers";

test.beforeEach(async ({ page }) => {
await seedConsent(page);
});

test.describe("RepoCard: whole-card is a clickable link", () => {
test("clicking the description dead-zone still opens the repo on GitHub", async ({
page,
context,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");

// Pause marquee auto-scroll while we click — otherwise the description
// moves under the pointer mid-click and the test flakes. Reducing motion
// disables auto-scroll in RepoMarquee.
await page.emulateMedia({ reducedMotion: "reduce" });
await page.reload();

// Pick a known DeFi repo; wait for at least one copy to be in the DOM.
const firstGoEth = page
.locator('article[data-repo-key="ethereum/go-ethereum"]')
.first();
await expect(firstGoEth).toBeVisible();

// Click the description paragraph — not the title text. Before this fix
// clicking here was a dead zone.
const descParagraph = firstGoEth.locator("p").first();
await expect(descParagraph).toBeVisible();

// The stretched-link ::after overlay intercepts pointer events on the
// description paragraph — exactly the behaviour we're verifying. That
// trips Playwright's actionability check for the paragraph, so bypass it
// with `force: true`; the click still lands at the paragraph's centre,
// which is what a real user would do. Whatever element sits on top (the
// stretched anchor) receives the event.
const [newPage] = await Promise.all([
context.waitForEvent("page"),
descParagraph.click({ force: true }),
]);
await newPage.waitForLoadState("domcontentloaded").catch(() => {});
expect(newPage.url()).toContain("github.com/ethereum/go-ethereum");
await newPage.close();
});

test("duplicate-copy cards in the marquee are still clickable (inert regression)", async ({
page,
context,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");

// The marquee renders 3 copies of each card for the seamless loop. The
// auto-scroll parks the viewport in the middle copy, so visible cards are
// almost always the duplicates. Verify a duplicate copy still navigates
// — the previous `inert={true}` on duplicates blocked all pointer events.
const copies = page.locator('article[data-repo-key="ethereum/go-ethereum"]');
// Under reduced motion the marquee skips duplicates entirely. Re-enable
// motion just for this spec so all three copies render.
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.reload();
// Wait for at least 2 copies to render (reduced-motion disables duplicates
// in some browsers; this is the cross-browser-safe guard).
await expect.poll(async () => copies.count()).toBeGreaterThanOrEqual(2);

// Pick a copy that is NOT the first (index 0 is the interactive-by-default
// copy; pre-fix the duplicates were inert and failed).
const duplicate = copies.nth(1);
await duplicate.scrollIntoViewIfNeeded();
await expect(duplicate).toBeVisible();

// The duplicate should be inside an aria-hidden wrapper — a11y guard.
const ariaHiddenParent = duplicate.locator(
'xpath=ancestor::div[@aria-hidden="true"][1]',
);
await expect(ariaHiddenParent).toBeAttached();

// Click the description of the duplicate — pre-fix this was a no-op.
const desc = duplicate.locator("p").first();
const [newPage] = await Promise.all([
context.waitForEvent("page"),
desc.click({ force: true }),
]);
await newPage.waitForLoadState("domcontentloaded").catch(() => {});
expect(newPage.url()).toContain("github.com/ethereum/go-ethereum");
await newPage.close();
});

test("duplicate-copy anchors are out of the Tab order (tabindex=-1)", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
const copies = page.locator('article[data-repo-key="ethereum/go-ethereum"]');
await expect.poll(async () => copies.count()).toBeGreaterThanOrEqual(2);

const firstAnchor = copies.nth(0).getByRole("link");
// First copy is interactive — no tabindex override.
await expect(firstAnchor).not.toHaveAttribute("tabindex", /.*/);

// Duplicate copies drop out of the Tab order.
const dupAnchor = copies.nth(1).locator("a[href^='https://github.com/']");
await expect(dupAnchor).toHaveAttribute("tabindex", "-1");
});

test("anchor has aria-label naming the destination repo and opens in new tab", async ({
page,
}) => {
await page.goto("/");
const firstGoEth = page
.locator('article[data-repo-key="ethereum/go-ethereum"]')
.first();
const link = firstGoEth.getByRole("link", {
name: /ethereum\/go-ethereum on GitHub, opens in new tab/i,
});
await expect(link).toBeVisible();
await expect(link).toHaveAttribute("target", "_blank");
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
80 changes: 80 additions & 0 deletions frontend/src/components/repo-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,86 @@ describe("RepoCard", () => {
});
});

describe("stretched-link / whole-card click target", () => {
it("title anchor exposes target, rel, aria-label, and the card href", () => {
render(<RepoCard repo={repo} status="unstarred" />);
const anchor = screen.getByRole("link", {
name: /ethereum\/go-ethereum on GitHub, opens in new tab/i,
});
expect(anchor).toHaveAttribute("href", repo.url);
expect(anchor).toHaveAttribute("target", "_blank");
expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
});

it("card article has `relative` so the stretched ::after overlay is positioned to it", () => {
const { container } = render(<RepoCard repo={repo} status="unstarred" />);
const article = container.querySelector("article") as HTMLElement;
expect(article.className).toMatch(/\brelative\b/);
});

it("title anchor applies the stretched-link overlay classes (after:absolute + after:inset-0)", () => {
const { container } = render(<RepoCard repo={repo} status="unstarred" />);
const anchor = container.querySelector("a[href='" + repo.url + "']") as HTMLElement;
expect(anchor.className).toMatch(/after:absolute/);
expect(anchor.className).toMatch(/after:inset-0/);
});

it("wraps the StarIndicator in a `relative z-10` container so the retry button escapes the overlay", () => {
render(<RepoCard repo={repo} status="failed" onRetry={vi.fn()} />);
const retryBtn = screen.getByRole("button", { name: /retry starring/i });
const wrapper = retryBtn.closest("div") as HTMLElement;
expect(wrapper.className).toMatch(/\brelative\b/);
expect(wrapper.className).toMatch(/\bz-10\b/);
});

it("removes anchor and retry button from the Tab order when focusable={false}", () => {
render(<RepoCard repo={repo} status="failed" onRetry={vi.fn()} focusable={false} />);
const anchor = screen.getByRole("link", {
name: /ethereum\/go-ethereum on GitHub, opens in new tab/i,
});
expect(anchor).toHaveAttribute("tabindex", "-1");
const retryBtn = screen.getByRole("button", { name: /retry starring/i });
expect(retryBtn).toHaveAttribute("tabindex", "-1");
});

it("keeps anchor and retry button tabbable by default (focusable omitted)", () => {
render(<RepoCard repo={repo} status="failed" onRetry={vi.fn()} />);
const anchor = screen.getByRole("link", {
name: /ethereum\/go-ethereum on GitHub, opens in new tab/i,
});
expect(anchor).not.toHaveAttribute("tabindex");
const retryBtn = screen.getByRole("button", { name: /retry starring/i });
expect(retryBtn).not.toHaveAttribute("tabindex");
});

it("clicking the retry button calls onRetry and does NOT follow the card link", async () => {
const onRetry = vi.fn();
render(<RepoCard repo={repo} status="failed" onRetry={onRetry} />);
const retryBtn = screen.getByRole("button", { name: /retry starring/i });

// Stub window.open to catch accidental navigations.
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);

// Simulate a real click that also bubbles — mirrors user-event behaviour
// but lets us assert the anchor's default action didn't fire.
let anchorClicked = false;
const anchor = screen.getByRole("link", {
name: /ethereum\/go-ethereum on GitHub, opens in new tab/i,
});
anchor.addEventListener("click", (e) => {
anchorClicked = true;
e.preventDefault();
});

await userEvent.click(retryBtn);

expect(onRetry).toHaveBeenCalledWith(repo);
expect(anchorClicked).toBe(false);
expect(openSpy).not.toHaveBeenCalled();
openSpy.mockRestore();
});
});

it("shows no skeletons when data is loaded", () => {
const { container } = render(
<RepoCard
Expand Down
33 changes: 27 additions & 6 deletions frontend/src/components/repo-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ interface RepoCardProps {
liveDescription?: string | null;
metaLoading?: boolean;
onRetry?: (repo: Repository) => void;
// The marquee renders three DOM copies to drive a seamless loop; only one
// copy should receive keyboard focus. Set false on the aria-hidden duplicates
// to drop the anchor and retry button out of the Tab order.
focusable?: boolean;
}

export const RepoCard = memo(function RepoCard({
Expand All @@ -35,7 +39,9 @@ export const RepoCard = memo(function RepoCard({
liveDescription,
metaLoading,
onRetry,
focusable = true,
}: RepoCardProps) {
const tabIndex = focusable ? undefined : -1;
const handleRetry = useCallback(() => {
onRetry?.(repo);
}, [onRetry, repo]);
Expand All @@ -49,7 +55,7 @@ export const RepoCard = memo(function RepoCard({
<article
data-repo-key={repoKey(repo)}
className={cn(
"glass glass-hover group flex h-36 w-[240px] shrink-0 flex-col justify-between rounded-xl p-4 transition-all md:h-44 md:w-[320px] md:p-5",
"glass glass-hover group relative flex h-36 w-[240px] shrink-0 flex-col justify-between rounded-xl p-4 transition-all md:h-44 md:w-[320px] md:p-5",
"hover:eth-glow",
)}
>
Expand All @@ -58,16 +64,28 @@ export const RepoCard = memo(function RepoCard({
href={repo.url}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 flex-1"
tabIndex={tabIndex}
aria-label={`${repo.owner}/${repo.name} on GitHub, opens in new tab`}
className={cn(
"min-w-0 flex-1",
// Stretched-link overlay: the ::after pseudo covers the whole card
// so clicks anywhere inside the article hit the anchor.
"after:absolute after:inset-0 after:rounded-xl",
// Focus ring moves from the title underline to the whole card.
"focus-visible:outline-none focus-visible:after:ring-[3px] focus-visible:after:ring-ring/50",
)}
>
<h3 className="truncate font-heading text-sm font-semibold text-primary group-hover:underline md:text-base">
{repo.owner}/{repo.name}
</h3>
</a>
<StarIndicator
status={status}
onRetry={onRetry ? handleRetry : undefined}
/>
<div className="relative z-10">
<StarIndicator
status={status}
onRetry={onRetry ? handleRetry : undefined}
tabIndex={tabIndex}
/>
</div>
</div>

{metaLoading && !liveDescription ? (
Expand Down Expand Up @@ -141,9 +159,11 @@ const RepoAvatar = memo(function RepoAvatar({ owner }: { owner: string }) {
function StarIndicator({
status,
onRetry,
tabIndex,
}: {
status: StarStatus;
onRetry?: () => void;
tabIndex?: number;
}) {
if (status === "checking") {
return <Skeleton className="h-5 w-5 rounded-full" aria-label="Checking" />;
Expand Down Expand Up @@ -174,6 +194,7 @@ function StarIndicator({
<button
type="button"
onClick={onRetry}
tabIndex={tabIndex}
className="rounded-full text-destructive transition-colors hover:text-primary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
aria-label="Retry starring"
>
Expand Down
Loading
Loading