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
2 changes: 1 addition & 1 deletion apps/www/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function HomePage() {
<SailButton />
<StarUsButton />
</div>
<div className="mt-4 sm:mt-8 sm:px-8 max-w-5xl mx-auto w-full">
<div className="sm:mt-8 sm:px-2 max-w-5xl mx-auto w-full">
<VideoDemo />
</div>
</main>
Expand Down
76 changes: 54 additions & 22 deletions apps/www/components/page/video-demo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,27 @@ vi.mock("next/image", () => ({
alt,
width,
height,
fill,
className,
sizes,
}: {
src: string;
alt?: string;
width?: number;
height?: number;
fill?: boolean;
className?: string;
sizes?: string;
}) => (
// biome-ignore lint/performance/noImgElement: Mock for testing
<img
src={src}
alt={alt}
width={width}
height={height}
width={fill ? undefined : width}
height={fill ? undefined : height}
className={className}
sizes={sizes}
data-fill={fill ? "true" : undefined}
/>
),
}));
Expand All @@ -56,7 +62,7 @@ vi.mock("next-video/background-video", () => ({
const videoSrc =
typeof src === "string" ? src : src?.src || "/videos/demo.mp4";

// Forward all props (including onError, width, className, etc.)
// Forward all props (including onError, className, etc.)
// so tests see real behavior
// Set poster to props.poster || "/assets/demo.png" so the mock reflects
// the actual poster prop when provided
Expand All @@ -69,9 +75,7 @@ vi.mock("next-video/background-video", () => ({
{...props}
src={videoSrc}
poster={poster || "/assets/demo.png"}
className={
className || "block max-h-[600px] sm:max-h-[1000px] object-contain"
}
className={className || "absolute inset-0 w-full h-full object-contain"}
>
You need a browser that supports HTML5 video to view this video.
</video>
Expand Down Expand Up @@ -115,12 +119,12 @@ describe("VideoDemo", () => {
expect(video).toHaveAttribute("loop");
expect(video).toHaveProperty("muted", true);
expect(video).toHaveAttribute("playsinline");
expect(video).toHaveAttribute("width", "800");
expect(video).toHaveAttribute("poster", "/assets/demo.png");
expect(video).toHaveClass(
"block",
"max-h-[600px]",
"sm:max-h-[1000px]",
"absolute",
"inset-0",
"w-full",
"h-full",
"object-contain",
);
});
Expand Down Expand Up @@ -160,6 +164,7 @@ describe("VideoDemo", () => {
"cursor-pointer",
"m-0",
"p-0",
"w-full",
"shadow-[0_0_20px_rgba(96,165,250,0.6)]",
"dark:shadow-[0_0_100px_rgba(96,165,250,0.2)]",
);
Expand Down Expand Up @@ -228,20 +233,27 @@ describe("VideoDemo", () => {
render(<VideoDemo />);

const container = screen.getByRole("button").parentElement;
expect(container).toHaveClass("inline-block", "relative", "mb-4");
expect(container).toHaveClass(
"relative",
"mb-4",
"w-full",
"max-w-[800px]",
"mx-auto",
"sm:px-8",
);
});

it("video has correct dimensions and styling", () => {
render(<VideoDemo />);

const video = screen.getByRole("button").querySelector("video");
expect(video).toHaveClass(
"block",
"max-h-[600px]",
"sm:max-h-[1000px]",
"absolute",
"inset-0",
"w-full",
"h-full",
"object-contain",
);
expect(video).toHaveAttribute("width", "800");
});

it("maintains video autoplay and loop behavior", () => {
Expand Down Expand Up @@ -303,13 +315,8 @@ describe("VideoDemo", () => {
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("src", "/assets/demo.gif");
expect(img).toHaveAttribute("alt", "ArkEnv Demo");
expect(img).toHaveAttribute("width", "800");
expect(img).toHaveClass(
"block",
"max-h-[600px]",
"sm:max-h-[1000px]",
"object-contain",
);
expect(img).toHaveAttribute("data-fill", "true");
expect(img).toHaveClass("object-contain");
});

it("maintains button click behavior after fallback to demo.gif", () => {
Expand All @@ -332,4 +339,29 @@ describe("VideoDemo", () => {
"noopener,noreferrer",
);
});

it("button has aspect ratio style for responsive scaling", () => {
render(<VideoDemo />);

const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
expect(button).toHaveStyle({ aspectRatio: "800 / 653" });
});

it("fallback image has sizes attribute and fill prop for responsive loading", () => {
render(<VideoDemo />);

const button = screen.getByRole("button");
const video = button.querySelector("video");

// Simulate video error to show fallback image
if (video) {
fireEvent.error(video);
}

const img = button.querySelector("img");
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("sizes", "(max-width: 768px) 100vw, 800px");
expect(img).toHaveAttribute("data-fill", "true");
});
});
17 changes: 8 additions & 9 deletions apps/www/components/page/video-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,34 +22,33 @@ export function VideoDemo() {
};

return (
<div className="inline-block relative mb-4">
{/* Main video container */}
<div className="relative mb-4 w-full max-w-[800px] mx-auto sm:px-8">
{/* Main video container with aspect ratio */}
<button
type="button"
className="relative rounded-lg overflow-hidden border border-fd-border shadow-lg bg-black/5 dark:bg-black/20 cursor-pointer m-0 p-0 shadow-[0_0_20px_rgba(96,165,250,0.6)] dark:shadow-[0_0_100px_rgba(96,165,250,0.2)]"
className="relative rounded-lg overflow-hidden border border-fd-border shadow-lg bg-black/5 dark:bg-black/20 cursor-pointer m-0 p-0 w-full shadow-[0_0_20px_rgba(96,165,250,0.6)] dark:shadow-[0_0_100px_rgba(96,165,250,0.2)]"
onClick={handleVideoClick}
aria-label="Open interactive demo in a new tab"
style={{ aspectRatio: `${WIDTH} / ${HEIGHT}` }}
>
{videoError ? (
<Image
src="/assets/demo.gif"
alt="ArkEnv Demo"
width={WIDTH}
height={HEIGHT}
className="block max-h-[600px] sm:max-h-[1000px] object-contain"
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, 800px"
/>
) : (
<BackgroundVideo
src={demo}
width={WIDTH}
height={HEIGHT}
poster="/assets/demo.png"
onError={handleVideoError}
autoPlay
loop
muted
playsInline
className="block max-h-[600px] sm:max-h-[1000px] object-contain"
className="absolute inset-0 w-full h-full object-contain"
/>
)}
</button>
Expand Down
178 changes: 178 additions & 0 deletions tooling/playwright-www/tests/responsive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { expect, test } from "@playwright/test";

test.describe("Responsive Design", () => {
// Mobile viewport sizes to test
const mobileViewports = [
{ name: "iPhone SE", width: 375, height: 667 },
{ name: "iPhone 12 Pro", width: 390, height: 844 },
{ name: "Samsung Galaxy S21", width: 360, height: 800 },
{ name: "Small Mobile", width: 320, height: 568 },
];

test.describe("Demo Video Responsiveness", () => {
for (const viewport of mobileViewports) {
test(`should not cause horizontal scrolling on ${viewport.name} (${viewport.width}x${viewport.height})`, async ({
page,
}) => {
// Set viewport to mobile size
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});

// Navigate to home page where the demo video is
await page.goto("/");
await page.waitForLoadState("networkidle");

// Wait for video to be visible
const videoButton = page.getByRole("button", {
name: /open interactive demo/i,
});
await expect(videoButton).toBeVisible();

// Check that body width doesn't exceed viewport width (no horizontal scroll)
const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
const viewportWidth = viewport.width;

// The body should not be wider than the viewport
expect(bodyWidth).toBeLessThanOrEqual(viewportWidth);
});

test(`should scale video to fit viewport on ${viewport.name} (${viewport.width}x${viewport.height})`, async ({
page,
}) => {
// Set viewport to mobile size
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});

// Navigate to home page
await page.goto("/");
await page.waitForLoadState("networkidle");

// Get the video container button
const videoButton = page.getByRole("button", {
name: /open interactive demo/i,
});
await expect(videoButton).toBeVisible();

// Get the video or image element's bounding box
const videoElement = page.locator("video, img[alt*='Demo' i]").first();
await expect(videoElement).toBeVisible();

const boundingBox = await videoElement.boundingBox();
expect(boundingBox).not.toBeNull();

if (boundingBox) {
// Video/image should not be wider than the viewport
// Allow for some padding/margins (e.g., 32px total)
const maxAllowedWidth = viewport.width - 32;
expect(boundingBox.width).toBeLessThanOrEqual(maxAllowedWidth);
}
});
}

test("should have responsive width classes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");

// Test the container button which has the aspect-ratio style
// This is what actually controls the responsive behavior
const button = page.getByRole("button", {
name: /open interactive demo/i,
});
await expect(button).toBeVisible();

// Container should have aspect-ratio style for responsive scaling
const buttonStyle = await button.getAttribute("style");
expect(buttonStyle).toBeTruthy();
expect(buttonStyle).toContain("aspect-ratio");

// Container should have w-full class for responsive width
const buttonClass = await button.getAttribute("class");
expect(buttonClass).toBeTruthy();
expect(buttonClass).toContain("w-full");

// Optionally check video element if it exists and has classes
const videoElement = page.locator("video").first();
const videoCount = await videoElement.count();

if (videoCount > 0) {
await page.waitForTimeout(500); // Wait for video to load
const videoClass = await videoElement.getAttribute("class");

// If video has classes, verify they're responsive
if (videoClass) {
expect(videoClass).toContain("object-contain");
// Should not have fixed width classes
const hasFixedWidth = /w-\d+/.test(videoClass);
expect(hasFixedWidth).toBe(false);
}
// If video doesn't have classes (BackgroundVideo might wrap it), that's fine
// The container's aspect-ratio is what matters for responsiveness
}
});

test("should maintain aspect ratio when resizing", async ({ page }) => {
// Start with desktop size
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/");
await page.waitForLoadState("networkidle");

// Test the container button which has the aspect-ratio style, not the media element
const containerButton = page.getByRole("button", {
name: /open interactive demo/i,
});
await expect(containerButton).toBeVisible();

// Get dimensions at desktop size
const desktopBox = await containerButton.boundingBox();
expect(desktopBox).not.toBeNull();

const desktopAspectRatio = desktopBox
? desktopBox.width / desktopBox.height
: 0;

// Resize to mobile
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(500); // Wait for resize to settle

// Get dimensions at mobile size
const mobileBox = await containerButton.boundingBox();
expect(mobileBox).not.toBeNull();

const mobileAspectRatio = mobileBox
? mobileBox.width / mobileBox.height
: 0;

// Aspect ratios should be approximately equal (within 10% tolerance for browser rendering differences)
const tolerance = 0.1;
const difference = Math.abs(desktopAspectRatio - mobileAspectRatio);
const percentDifference = difference / desktopAspectRatio;

expect(percentDifference).toBeLessThan(tolerance);
});

test("should not exceed maximum width on large screens", async ({
page,
}) => {
// Set to a very large viewport
await page.setViewportSize({ width: 2560, height: 1440 });
await page.goto("/");
await page.waitForLoadState("networkidle");

const mediaElement = page.locator("video, img[alt*='Demo' i]").first();
await expect(mediaElement).toBeVisible();

const boundingBox = await mediaElement.boundingBox();
expect(boundingBox).not.toBeNull();

if (boundingBox) {
// Video should not exceed its natural maximum width (800px)
// Allow a small margin for sub-pixel rendering
expect(boundingBox.width).toBeLessThanOrEqual(810);
}
});
});
});