import sharp from "sharp"; import type { Locator, Page, TestInfo } from "@playwright/test"; import { expect, test } from "../helpers/serve-dist"; interface VisualViewportCase { readonly label: string; readonly width: number; readonly height: number; } interface SampledFrame { readonly pixels: Uint8Array; readonly width: number; readonly height: number; } interface FlashEvent { readonly direction: -1 | 1; readonly frame: number; readonly affectedRatio: number; } const viewports = [ { label: "mobile-compact-portrait", width: 320, height: 568 }, { label: "mobile-portrait", width: 390, height: 844 }, { label: "mobile-landscape", width: 844, height: 390 }, { label: "tablet-portrait", width: 768, height: 1024 }, { label: "desktop-compact", width: 1280, height: 720 }, { label: "desktop-standard", width: 1440, height: 900 }, { label: "desktop-wide", width: 1920, height: 1080 }, { label: "desktop-ultrawide", width: 2560, height: 1440 }, ] as const satisfies readonly VisualViewportCase[]; const waitForRenderedPortfolio = async ( page: Page, distUrl: string, ): Promise => { await page.goto(distUrl, { waitUntil: "domcontentloaded" }); await page.evaluate(() => document.fonts.ready); await expect( page.getByRole("heading", { level: 1, name: "Luca Pîslaru" }), ).toBeVisible(); await expect(page.locator(".journey-world")).toHaveAttribute( "data-scene-state", "running", ); }; const captureViewport = async ( page: Page, testInfo: TestInfo, name: string, ): Promise => page.screenshot({ animations: "allow", path: testInfo.outputPath(`${name}.png`), scale: "css", }); const sampleFrame = async ( image: Buffer, width = 160, height = 90, ): Promise => { const { data, info } = await sharp(image) .resize(width, height, { fit: "fill" }) .removeAlpha() .raw() .toBuffer({ resolveWithObject: true }); return { pixels: data, width: info.width, height: info.height }; }; const paletteRatios = async ( image: Buffer, ): Promise<{ dark: number; restrainedAccent: number }> => { const frame = await sampleFrame(image, 120, 68); let dark = 0; let accent = 0; for (let offset = 0; offset < frame.pixels.length; offset += 3) { const red = frame.pixels[offset] ?? 0; const green = frame.pixels[offset + 1] ?? 0; const blue = frame.pixels[offset + 2] ?? 0; const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; const chroma = Math.max(red, green, blue) - Math.min(red, green, blue); if (luminance < 86) dark += 1; if (chroma >= 34 && luminance >= 35) accent += 1; } const pixelCount = frame.width * frame.height; return { dark: dark / pixelCount, restrainedAccent: accent / pixelCount }; }; const averageNormalizedDifference = async ( first: Buffer, second: Buffer, ): Promise => { const [a, b] = await Promise.all([sampleFrame(first), sampleFrame(second)]); expect({ width: a.width, height: a.height }).toEqual({ width: b.width, height: b.height, }); let difference = 0; for (let offset = 0; offset < a.pixels.length; offset += 1) { difference += Math.abs((a.pixels[offset] ?? 0) - (b.pixels[offset] ?? 0)); } return difference / (a.pixels.length * 255); }; const captureWorldSequence = async ( world: Locator, frameCount: number, intervalMs: number, ): Promise => { const frames: SampledFrame[] = []; const clip = await world.boundingBox(); if (clip === null) throw new Error("Journey world is not visible."); for (let index = 0; index < frameCount; index += 1) { frames.push( await sampleFrame( await world .page() .screenshot({ animations: "allow", clip, scale: "css" }), ), ); if (index + 1 < frameCount) await world.page().waitForTimeout(intervalMs); } return frames; }; const relativeLuminance = (pixels: Uint8Array, offset: number): number => (0.2126 * (pixels[offset] ?? 0) + 0.7152 * (pixels[offset + 1] ?? 0) + 0.0722 * (pixels[offset + 2] ?? 0)) / 255; const analyzeFlashSequence = ( frames: readonly SampledFrame[], framesPerSecond: number, ): { events: FlashEvent[]; maximumFlashesPerSecond: number } => { const events: FlashEvent[] = []; for (let frameIndex = 1; frameIndex < frames.length; frameIndex += 1) { const previous = frames[frameIndex - 1]; const current = frames[frameIndex]; if (previous === undefined || current === undefined) continue; expect({ width: current.width, height: current.height }).toEqual({ width: previous.width, height: previous.height, }); let brightened = 0; let darkened = 0; const pixelCount = current.width * current.height; for (let offset = 0; offset < current.pixels.length; offset += 3) { const before = relativeLuminance(previous.pixels, offset); const after = relativeLuminance(current.pixels, offset); const delta = after - before; // The WCAG general-flash luminance boundary: a >=0.1 change with the // darker state below 0.8. A quarter-screen area is intentionally more // conservative than the normative small-area exclusion. if (Math.abs(delta) >= 0.1 && Math.min(before, after) < 0.8) { if (delta > 0) brightened += 1; else darkened += 1; } } const direction = brightened >= darkened ? 1 : -1; const affectedRatio = Math.max(brightened, darkened) / pixelCount; if (affectedRatio >= 0.25) events.push({ direction, frame: frameIndex, affectedRatio }); } let maximumFlashesPerSecond = 0; const windowFrames = Math.max(1, Math.round(framesPerSecond)); for (let startFrame = 1; startFrame < frames.length; startFrame += 1) { const windowEvents = events.filter( (event) => event.frame >= startFrame && event.frame < startFrame + windowFrames, ); let reversals = 0; for (let index = 1; index < windowEvents.length; index += 1) { if (windowEvents[index]?.direction !== windowEvents[index - 1]?.direction) reversals += 1; } maximumFlashesPerSecond = Math.max( maximumFlashesPerSecond, Math.ceil(reversals / 2), ); } return { events, maximumFlashesPerSecond }; }; test.describe("production visual composition", () => { test.beforeEach(async ({}, testInfo) => { test.skip( testInfo.project.name !== "desktop-chromium", "Visual evidence is captured once in the available current Chromium engine.", ); }); for (const viewport of viewports) { test(`${viewport.label} keeps the professional information layer over the journey world`, async ({ page, distUrl, }, testInfo) => { await page.setViewportSize(viewport); await waitForRenderedPortfolio(page, distUrl); const composition = await page.evaluate(() => { const root = document.documentElement; const panels = [ ...document.querySelectorAll( ".portfolio-main > .portfolio-section", ), ]; const firstPanel = panels[0]; if (firstPanel === undefined) throw new Error("Missing portfolio panel."); const firstPanelStyle = getComputedStyle(firstPanel); const textStyles = [ ...document.querySelectorAll( ".portfolio-main :is(h2,h3,p)", ), ].map((element) => getComputedStyle(element)); const boxes = [ ...document.querySelectorAll( ".site-header, .portfolio-section, .drive-controls, .journey-hud", ), ] .filter((element) => getComputedStyle(element).display !== "none") .map((element) => element.getBoundingClientRect()) .filter((rect) => rect.width > 0 && rect.height > 0); const fixedSurfaces = [ ...document.querySelectorAll( ".site-navigation, .drive-controls, .journey-hud", ), ] .filter( (element) => getComputedStyle(element).position === "fixed" && getComputedStyle(element).display !== "none", ) .map((element) => ({ selector: element.className, rect: element.getBoundingClientRect(), })); const fixedSurfaceOverlaps = fixedSurfaces.flatMap((surface, index) => fixedSurfaces .slice(index + 1) .filter( (candidate) => Math.max( 0, Math.min(surface.rect.right, candidate.rect.right) - Math.max(surface.rect.left, candidate.rect.left), ) * Math.max( 0, Math.min(surface.rect.bottom, candidate.rect.bottom) - Math.max(surface.rect.top, candidate.rect.top), ) > 1, ) .map( (candidate) => `${surface.selector} overlaps ${candidate.selector}`, ), ); return { noHorizontalOverflow: root.scrollWidth <= root.clientWidth + 1, boxesFitHorizontally: boxes.every( (rect) => rect.left >= -1 && rect.right <= root.clientWidth + 1, ), panelGeometry: panels.map((panel) => ({ layout: panel.dataset.layout ?? "", width: panel.getBoundingClientRect().width, widthRatio: panel.getBoundingClientRect().width / root.clientWidth, })), panelBackgroundImage: firstPanelStyle.backgroundImage, panelFilter: firstPanelStyle.filter, textShadows: textStyles.map((style) => style.textShadow), publicProjectMediaCount: document.querySelectorAll( ".project-media img, .project-media video", ).length, controlHeightRatio: (document .querySelector(".drive-controls") ?.getBoundingClientRect().height ?? 0) / window.innerHeight, fixedSurfaceOverlaps, }; }); const screenshot = await captureViewport( page, testInfo, `visual-${viewport.label}-default`, ); const palette = await paletteRatios(screenshot); expect(composition.noHorizontalOverflow).toBe(true); expect(composition.boxesFitHorizontally).toBe(true); expect(composition.panelBackgroundImage).toBe("none"); expect(composition.panelFilter).toBe("none"); expect(composition.textShadows.every((shadow) => shadow === "none")).toBe( true, ); expect(composition.publicProjectMediaCount).toBe(0); expect(composition.fixedSurfaceOverlaps).toEqual([]); expect(palette.dark).toBeGreaterThan(0.58); expect(palette.restrainedAccent).toBeGreaterThan(0.0005); expect(palette.restrainedAccent).toBeLessThan(0.28); if (viewport.width >= 1280) { for (const panel of composition.panelGeometry) { if (["right-rail", "compact-rail"].includes(panel.layout)) { expect(panel.widthRatio).toBeGreaterThanOrEqual(0.4); expect(panel.widthRatio).toBeLessThanOrEqual(0.45); } else if (panel.layout === "left-timeline") { expect(panel.widthRatio).toBeGreaterThanOrEqual(0.44); expect(panel.widthRatio).toBeLessThanOrEqual(0.48); } else if (["full-diagram", "toolkit-field"].includes(panel.layout)) { expect(panel.widthRatio).toBeGreaterThanOrEqual(0.75); expect(panel.widthRatio).toBeLessThanOrEqual(0.8); } else if (panel.layout === "project-stage") { expect(panel.widthRatio).toBeGreaterThanOrEqual(0.9); expect(panel.widthRatio).toBeLessThanOrEqual(0.94); } else if (panel.layout === "destination") { expect(panel.width).toBeGreaterThanOrEqual(760); expect(panel.width).toBeLessThanOrEqual(866); } } } else if (viewport.width < 768) { expect( composition.panelGeometry.every( (panel) => panel.widthRatio >= 0.85 && panel.widthRatio <= 1, ), ).toBe(true); } if (viewport.width <= 390 && viewport.height <= 568) { expect(composition.controlHeightRatio).toBeLessThanOrEqual(0.34); } }); } test("Static is screenshot-stable and retains the same professional content", async ({ page, distUrl, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await waitForRenderedPortfolio(page, distUrl); await page.getByRole("button", { name: "Drive settings" }).click(); await page.getByLabel("Quality control").selectOption("static"); await expect(page.locator(".journey-world")).toHaveAttribute( "data-scene-state", "static", ); await expect(page.locator(".journey-canvas")).toHaveCount(0); await expect(page.locator(".journey-poster")).toHaveCount(1); await page.keyboard.press("Escape"); await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur(), ); await page.mouse.move(1, 1); const first = await captureViewport(page, testInfo, "visual-static-a"); await page.waitForTimeout(200); const second = await captureViewport(page, testInfo, "visual-static-b"); expect(await averageNormalizedDifference(first, second)).toBeLessThan( 0.001, ); }); test("reduced motion uses the stable poster without requesting the live scene", async ({ page, distUrl, }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(distUrl, { waitUntil: "domcontentloaded" }); await page.evaluate(() => document.fonts.ready); await expect(page.locator(".journey-canvas")).toHaveCount(0); await expect(page.locator(".journey-poster")).toHaveCount(1); await page.getByRole("button", { name: "Drive settings" }).click(); await expect(page.getByRole("status")).toContainText( "reduced motion requested", ); await page.keyboard.press("Escape"); await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur(), ); await page.mouse.move(1, 1); await captureViewport(page, testInfo, "visual-reduced-motion"); }); test("audio-running state stays visually restrained and exposes its controls", async ({ page, distUrl, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await waitForRenderedPortfolio(page, distUrl); await page.getByRole("button", { name: "PLAY SOUNDTRACK" }).click(); await expect( page.getByRole("button", { name: "Mute sound" }), ).toBeVisible(); await page.getByRole("button", { name: "Sound volume" }).click(); await expect(page.getByLabel("Sound volume level")).toHaveValue("0.4"); await captureViewport(page, testInfo, "visual-audio-running"); }); test("media failure leaves one composed fallback and usable information", async ({ page, distUrl, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.addInitScript(() => { const nativeGetContext = HTMLCanvasElement.prototype.getContext; Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { configurable: true, value( this: HTMLCanvasElement, contextId: string, options?: unknown, ): RenderingContext | null { if ( contextId === "webgl" || contextId === "webgl2" || contextId === "experimental-webgl" ) return null; return Reflect.apply(nativeGetContext, this, [ contextId, options, ]) as RenderingContext | null; }, }); }); await page.goto(distUrl, { waitUntil: "domcontentloaded" }); await expect(page.locator(".journey-world")).toHaveAttribute( "data-scene-state", "failed", ); await expect(page.locator(".journey-poster")).toHaveCount(1); await expect(page.locator(".journey-canvas")).toHaveCount(0); await expect( page.getByRole("heading", { level: 1, name: "Luca Pîslaru" }), ).toBeVisible(); await captureViewport(page, testInfo, "visual-media-failure"); }); test("missing optional social profiles preserves the contact destination", async ({ page, distUrl, }, testInfo) => { await page.setViewportSize({ width: 768, height: 1024 }); await waitForRenderedPortfolio(page, distUrl); await page.locator('#contact a[target="_blank"]').evaluateAll((links) => { for (const link of links) link.closest("li")?.remove(); }); await page.locator('nav a[href="#contact"]').click(); await expect(page.locator("#contact-heading")).toBeFocused(); await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible(); await expect(page.locator('#contact a[target="_blank"]')).toHaveCount(0); await captureViewport(page, testInfo, "visual-missing-social"); }); test("Contact arrival resolves the route, HUD, identity, and primary action together", async ({ page, distUrl, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await waitForRenderedPortfolio(page, distUrl); await page.locator('nav a[href="#contact"]').click(); await expect(page.locator("#contact-heading")).toBeFocused(); await expect(page.locator(".journey-hud__section")).toHaveText("Contact"); await expect(page.locator(".journey-hud__arrival")).toHaveText("ARRIVED"); await expect(page.locator("#contact")).toBeInViewport(); await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible(); await captureViewport(page, testInfo, "visual-contact-arrival"); }); }); test.describe("three-flash safety", () => { test.beforeEach(async ({}, testInfo) => { test.skip( testInfo.project.name !== "desktop-chromium", "Automated flash evidence runs in the available current Chromium engine.", ); }); test("high-speed live scene remains below the conservative three-flash threshold", async ({ page, distUrl, }, testInfo) => { test.setTimeout(60_000); await page.setViewportSize({ width: 1440, height: 900 }); await waitForRenderedPortfolio(page, distUrl); await page.mouse.wheel(0, 900); await page.mouse.wheel(0, 900); const frames = await captureWorldSequence( page.locator(".journey-world"), 20, 100, ); const result = analyzeFlashSequence(frames, 10); await testInfo.attach("live-scene-flash-analysis.json", { body: Buffer.from(JSON.stringify(result, null, 2)), contentType: "application/json", }); expect(result.maximumFlashesPerSecond).toBeLessThanOrEqual(3); }); test("Static and reduced-motion poster sequences contain no qualifying flash", async ({ page, distUrl, }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await waitForRenderedPortfolio(page, distUrl); await page.getByRole("button", { name: "Drive settings" }).click(); await page.getByLabel("Quality control").selectOption("static"); await expect(page.locator(".journey-world")).toHaveAttribute( "data-scene-state", "static", ); await page.keyboard.press("Escape"); await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur(), ); await page.mouse.move(1, 1); const staticResult = analyzeFlashSequence( await captureWorldSequence(page.locator(".journey-world"), 8, 100), 10, ); await testInfo.attach("static-flash-analysis.json", { body: Buffer.from(JSON.stringify(staticResult, null, 2)), contentType: "application/json", }); expect(staticResult.maximumFlashesPerSecond).toBe(0); await page.emulateMedia({ reducedMotion: "reduce" }); await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.locator(".journey-canvas")).toHaveCount(0); const reducedResult = analyzeFlashSequence( await captureWorldSequence(page.locator(".journey-world"), 8, 100), 10, ); await testInfo.attach("reduced-motion-flash-analysis.json", { body: Buffer.from(JSON.stringify(reducedResult, null, 2)), contentType: "application/json", }); expect(reducedResult.maximumFlashesPerSecond).toBe(0); }); });