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
85 changes: 79 additions & 6 deletions e2e/capcheck.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,43 @@ async function expectSafeExternalLink(link: Locator) {
await expect(link).toHaveAttribute("rel", /\bnoreferrer\b/);
}

async function expectExternalIconOnLastGlyphLine(link: Locator) {
const metrics = await link.evaluate((anchor) => {
const icon = anchor.querySelector("svg");
if (!icon) throw new Error("Expected an external-link icon");

const walker = document.createTreeWalker(anchor, NodeFilter.SHOW_TEXT);
let lastText: Text | null = null;
let lastIndex = -1;
while (walker.nextNode()) {
const text = walker.currentNode as Text;
for (let index = text.data.length - 1; index >= 0; index -= 1) {
if (!/[\s\u2060]/u.test(text.data[index])) {
lastText = text;
lastIndex = index;
break;
}
}
}
if (!lastText) throw new Error("Expected visible link text");

const range = document.createRange();
range.setStart(lastText, lastIndex);
range.setEnd(lastText, lastIndex + 1);
const glyph = range.getBoundingClientRect();
const iconBox = icon.getBoundingClientRect();
return {
glyphCenter: glyph.top + glyph.height / 2,
iconCenter: iconBox.top + iconBox.height / 2,
height: anchor.getBoundingClientRect().height,
lineHeight: Number.parseFloat(getComputedStyle(anchor).lineHeight),
};
});

expect(Math.abs(metrics.glyphCenter - metrics.iconCenter)).toBeLessThan(4);
return metrics;
}

async function openTab(page: Page, name: RegExp) {
await page.getByRole("tab", { name }).click();
}
Expand Down Expand Up @@ -224,9 +261,14 @@ test("mixed result exposes every claim and safe evidence destination, then re-ch
await expect(page.getByText(/hype is shown separately and does not add points/)).toBeVisible();
await expect(page.getByText(/Evidence may be high, medium, or low trust—or unavailable/)).toBeVisible();
await expect(page.locator(".app-footer a")).toHaveCount(0);
await expectSafeExternalLink(
page.getByRole("link", { name: /Open the checked video/ }),
);
const checkedVideoLink = page.getByRole("link", { name: /Open the checked video/ });
await expectSafeExternalLink(checkedVideoLink);
await checkedVideoLink.evaluate((link) => {
link.style.display = "inline-block";
link.style.width = "150px";
});
const checkedVideoWrap = await expectExternalIconOnLastGlyphLine(checkedVideoLink);
expect(checkedVideoWrap.height).toBeGreaterThan(checkedVideoWrap.lineHeight * 1.5);

// Claims tab is active by default.
await expect(page.getByRole("tab", { name: /Claims reviewed/ })).toHaveAttribute(
Expand All @@ -250,6 +292,7 @@ test("mixed result exposes every claim and safe evidence destination, then re-ch
await expect(card.getByText(/trust$|Primary source/).first()).toBeVisible();
for (const link of await card.getByRole("link").all()) {
await expectSafeExternalLink(link);
await expectExternalIconOnLastGlyphLine(link);
}
await summary.click();
await expect(card).not.toHaveAttribute("open", /.*/);
Expand All @@ -265,9 +308,9 @@ test("mixed result exposes every claim and safe evidence destination, then re-ch
// Opinions are not fact-checked and expose no evidence links.
await expect(skippedOpinion.getByRole("link")).toHaveCount(0);

await expectSafeExternalLink(
page.getByRole("link", { name: /Open strongest source/ }),
);
const strongestSource = page.getByRole("link", { name: /Open strongest source/ });
await expectSafeExternalLink(strongestSource);
await expectExternalIconOnLastGlyphLine(strongestSource);

// Hype language lives in its own tab.
await openTab(page, /Hype language/);
Expand All @@ -293,11 +336,13 @@ test("mixed result exposes every claim and safe evidence destination, then re-ch
name: /Open evidence source: Understanding investment risk/,
});
await expectSafeExternalLink(indexSource);
await expectExternalIconOnLastGlyphLine(indexSource);
await expect(indexSource).toHaveAttribute(
"href",
"https://www.spglobal.com/spdji/en/indices/equity/sp-500/",
);
await expectSafeExternalLink(riskSource);
await expectExternalIconOnLastGlyphLine(riskSource);
await expect(riskSource).toHaveAttribute(
"href",
"https://www.finra.org/investors/investing/investing-basics/risk",
Expand All @@ -319,6 +364,34 @@ test("mixed result exposes every claim and safe evidence destination, then re-ch
await expect(page.getByLabel(/Choose a video file/)).toBeVisible();
});

test("desktop landscape results emphasize the embed without changing the vertical rail", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "desktop rail sizing");

await submitUrl(page);
const verticalGrid = page.locator(".result-grid");
const verticalRail = page.locator(".source-rail");
await expect(verticalGrid).toHaveAttribute("data-embed", "vertical");
expect((await verticalRail.boundingBox())?.width).toBeCloseTo(300, 0);

await submitUrl(page, "partialFailure");
const landscapeGrid = page.locator(".result-grid");
const landscapeRail = page.locator(".source-rail");
await expect(landscapeGrid).toHaveAttribute("data-embed", "landscape");
expect((await landscapeRail.boundingBox())?.width).toBeCloseTo(400, 0);

const facadeBox = await landscapeRail.locator(".video-facade").boundingBox();
expect(facadeBox?.width).toBeGreaterThanOrEqual(398);
expect(facadeBox!.width / facadeBox!.height).toBeCloseTo(16 / 9, 2);
await expect(landscapeRail.locator(".source-details")).toHaveCSS(
"font-size",
"12px",
);
expect((await landscapeRail.getByRole("button", { name: "Run again" }).boundingBox())?.height)
.toBeLessThan(44);
});

test("result tabs support the complete keyboard navigation pattern", async ({ page }) => {
await submitUrl(page);
const claims = page.getByRole("tab", { name: /Claims reviewed/ });
Expand Down
30 changes: 30 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -630,12 +630,18 @@ input:disabled {
.source-details a svg,
.evidence a svg,
.steps a svg {
display: inline;
width: 12px;
height: 12px;
margin-left: 3px;
vertical-align: -1px;
}

.external-link-tail {
display: inline-block;
white-space: nowrap;
}

.source-rail {
position: sticky;
top: 24px;
Expand Down Expand Up @@ -707,6 +713,30 @@ input:disabled {
border-left: 1px solid var(--ink);
}

@media (min-width: 841px) {
.result-grid[data-embed="landscape"] {
grid-template-columns: minmax(0, 1fr) 400px;
}

.result-grid[data-embed="landscape"] .source-details {
gap: 4px;
padding: 12px;
font-size: 12px;
}

.result-grid[data-embed="landscape"] .source-details a,
.result-grid[data-embed="landscape"] .source-details .file-name,
.result-grid[data-embed="landscape"] .source-details .when {
font-size: 12px;
}

.result-grid[data-embed="landscape"] .result-actions .ghost {
min-height: 40px;
padding: 0 12px;
font-size: 12px;
}
}

.tabs {
display: flex;
overflow-x: auto;
Expand Down
32 changes: 32 additions & 0 deletions src/components/capcheck-app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ describe("CapCheckApp", () => {
"data-orientation",
"vertical",
);
expect(container.querySelector(".result-grid")).toHaveAttribute(
"data-embed",
"vertical",
);
});

it("marks the checked-video facade as landscape for a long-form source", async () => {
Expand Down Expand Up @@ -110,6 +114,34 @@ describe("CapCheckApp", () => {
"data-orientation",
"landscape",
);
expect(container.querySelector(".result-grid")).toHaveAttribute(
"data-embed",
"landscape",
);
});

it("joins every external-link icon to the final visible character", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
sseResponse({ type: "complete", scorecard: DEMO_SCORECARDS.mixed }),
),
);
const user = userEvent.setup();
const { container } = render(<CapCheckApp />);

await submitUrl(user);
await screen.findByText("52");

const externalIcons = container.querySelectorAll(
".strongest a svg, .source-details a svg, .evidence a svg, .steps a svg",
);
expect(externalIcons.length).toBeGreaterThan(0);
for (const icon of externalIcons) {
const tail = icon.closest(".external-link-tail");
expect(tail).not.toBeNull();
expect(tail?.textContent).toHaveLength(1);
}
});

it("shows the complete accessible Cap Score bands", async () => {
Expand Down
7 changes: 4 additions & 3 deletions src/components/claim-card.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ChevronDown, ExternalLink, TriangleAlert } from "lucide-react";
import { ChevronDown, TriangleAlert } from "lucide-react";

import type { Evidence, OpinionClaim, Verification } from "@/domain/analysis";
import { formatTimestamp } from "@/lib/format-timestamp";

import { ExternalLinkLabel } from "./external-link-label";

const verdictPills = {
true: { label: "True", tone: "v-true" },
"mostly-true": { label: "Mostly true", tone: "v-mostly" },
Expand Down Expand Up @@ -52,8 +54,7 @@ const EvidenceBlock = ({ evidence }: { evidence: Evidence }) => (
rel="noopener noreferrer"
aria-label={`Open source: ${evidence.title} (opens in new tab)`}
>
{evidence.publisher} — {evidence.title}
<ExternalLink aria-hidden="true" />
<ExternalLinkLabel text={`${evidence.publisher} — ${evidence.title}`} />
</a>
</footer>
</div>
Expand Down
16 changes: 16 additions & 0 deletions src/components/external-link-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ExternalLink } from "lucide-react";

export function ExternalLinkLabel({ text }: { text: string }) {
const characters = Array.from(text);
const finalCharacter = characters.pop();

return (
<>
{characters.join("")}
<span className="external-link-tail">
{finalCharacter}
<ExternalLink aria-hidden="true" />
</span>
</>
);
}
21 changes: 10 additions & 11 deletions src/components/scorecard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
ExternalLink,
Play,
ShieldCheck,
} from "lucide-react";
Expand All @@ -9,6 +8,7 @@ import { formatTimestamp } from "@/lib/format-timestamp";
import { sourceOrientation } from "@/lib/source-orientation";

import { ClaimCard } from "./claim-card";
import { ExternalLinkLabel } from "./external-link-label";
import { HowItWorks } from "./how-it-works";
import { CountUp } from "./react-bits/count-up";
import { ResultsTabs, type ResultsTab } from "./results-tabs";
Expand Down Expand Up @@ -50,6 +50,7 @@ export function ScorecardView({
);

const tone = toneClass[scorecard.capLabel];
const embedOrientation = sourceOrientation(scorecard.source);
const sourceTitle =
scorecard.source.title ??
(scorecard.source.kind === "upload" ? scorecard.source.fileName : "this video");
Expand Down Expand Up @@ -122,8 +123,7 @@ export function ScorecardView({
rel="noopener noreferrer"
aria-label={`Open evidence source: ${evidence.title} (opens in new tab)`}
>
{evidence.title}
<ExternalLink aria-hidden="true" />
<ExternalLinkLabel text={evidence.title} />
</a>
) : (
action.url && (
Expand All @@ -133,8 +133,7 @@ export function ScorecardView({
rel="noopener noreferrer"
aria-label={`${action.label} (opens in new tab)`}
>
Open resource
<ExternalLink aria-hidden="true" />
<ExternalLinkLabel text="Open resource" />
</a>
)
)}
Expand Down Expand Up @@ -172,7 +171,7 @@ export function ScorecardView({

return (
<div className="main">
<div className="result-grid">
<div className="result-grid" data-embed={embedOrientation}>
<div className="result-content">
<section
className="score-section"
Expand Down Expand Up @@ -203,8 +202,9 @@ export function ScorecardView({
rel="noopener noreferrer"
aria-label={`Open strongest source: ${strongest.title} (opens in new tab)`}
>
{strongest.publisher} — {strongest.title}
<ExternalLink aria-hidden="true" />
<ExternalLinkLabel
text={`${strongest.publisher} — ${strongest.title}`}
/>
</a>
</span>
)}
Expand All @@ -216,7 +216,7 @@ export function ScorecardView({
<aside className="source-rail" aria-label="Checked video">
<div
className="video-facade"
data-orientation={sourceOrientation(scorecard.source)}
data-orientation={embedOrientation}
aria-hidden="true"
>
<Play />
Expand All @@ -230,8 +230,7 @@ export function ScorecardView({
rel="noopener noreferrer"
aria-label="Open the checked video (opens in new tab)"
>
{displayUrl(scorecard.source.url)}
<ExternalLink aria-hidden="true" />
<ExternalLinkLabel text={displayUrl(scorecard.source.url)} />
</a>
) : (
<span className="file-name">{scorecard.source.fileName}</span>
Expand Down
Loading