diff --git a/e2e/capcheck.spec.ts b/e2e/capcheck.spec.ts index 19a14c2..ce810cc 100644 --- a/e2e/capcheck.spec.ts +++ b/e2e/capcheck.spec.ts @@ -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(); } @@ -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( @@ -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", /.*/); @@ -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/); @@ -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", @@ -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/ }); diff --git a/src/app/globals.css b/src/app/globals.css index 2a85eb7..41af0e3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; @@ -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; diff --git a/src/components/capcheck-app.test.tsx b/src/components/capcheck-app.test.tsx index eed10a7..0560648 100644 --- a/src/components/capcheck-app.test.tsx +++ b/src/components/capcheck-app.test.tsx @@ -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 () => { @@ -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(); + + 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 () => { diff --git a/src/components/claim-card.tsx b/src/components/claim-card.tsx index 5b6bd5e..065a688 100644 --- a/src/components/claim-card.tsx +++ b/src/components/claim-card.tsx @@ -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" }, @@ -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} -