import { expect, test } from "@playwright/test"; /** * Chrome tint behavior: * * - Top tint (status bar / notch / Dynamic Island): * .chrome-tint--top (a position: fixed
near the top of * the viewport) gets backgroundColor = HEADER_COLOR when the * site header is in view; its inline backgroundColor is cleared * (transparent) otherwise. iOS 26+ Safari samples this element * to tint the chrome. * * The tag is also updated for Chrome * and iOS ≤18 compatibility. * * - Bottom tint (home indicator area, behind the address bar): * .chrome-tint--bottom (a position: fixed
near the bottom * of the viewport) gets backgroundColor = FOOTER_COLOR when the * site footer is in view; cleared otherwise. * * Tests run against the local Herd site. */ const site = "http://community-works-collaborative.test/"; const HEADER_COLOR = "#032F46"; const FOOTER_COLOR = "#102C45"; test.describe("chrome tint", () => { test.use({ viewport: { width: 402, height: 874 } }); test("fixed chrome tint elements exist in the DOM", async ({ page }) => { await page.goto(site, { waitUntil: "networkidle" }); const counts = await page.evaluate(() => ({ top: document.querySelectorAll(".chrome-tint--top").length, bottom: document.querySelectorAll(".chrome-tint--bottom").length, })); expect(counts.top).toBe(1); expect(counts.bottom).toBe(1); }); test("fixed chrome tint elements are within the iOS 26 sampling windows", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); const layout = await page.evaluate(() => { const top = document.querySelector(".chrome-tint--top"); const bottom = document.querySelector(".chrome-tint--bottom"); const winW = window.innerWidth; const winH = window.innerHeight; const topR = top.getBoundingClientRect(); const bottomR = bottom.getBoundingClientRect(); const topCs = getComputedStyle(top); const bottomCs = getComputedStyle(bottom); return { winW, winH, top: { top: Math.round(topR.top), height: Math.round(topR.height), width: Math.round(topR.width), widthPct: Math.round((topR.width / winW) * 100), position: topCs.position, }, bottom: { bottomFromViewport: Math.round(winH - bottomR.bottom), height: Math.round(bottomR.height), width: Math.round(bottomR.width), widthPct: Math.round((bottomR.width / winW) * 100), position: bottomCs.position, }, }; }); // Top element: position fixed, top ≤ 4px, width >= 80%, height >= 12px. expect(layout.top.position).toBe("fixed"); expect(layout.top.top).toBeLessThanOrEqual(4); expect(layout.top.widthPct).toBeGreaterThanOrEqual(80); expect(layout.top.height).toBeGreaterThanOrEqual(12); // Bottom element: position fixed, within 3px of viewport bottom, width >= 80%. expect(layout.bottom.position).toBe("fixed"); expect(layout.bottom.bottomFromViewport).toBeLessThanOrEqual(3); expect(layout.bottom.widthPct).toBeGreaterThanOrEqual(80); }); test("viewport meta includes viewport-fit=cover", async ({ page }) => { await page.goto(site, { waitUntil: "networkidle" }); const viewport = await page.evaluate(() => { const meta = document.querySelector('meta[name="viewport"]'); return meta ? meta.getAttribute("content") : null; }); expect(viewport).toContain("viewport-fit=cover"); }); test("static theme-color meta is present in the document head", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); const content = await page.evaluate(() => { const meta = document.querySelector('meta[name="theme-color"]'); return meta ? meta.getAttribute("content") : null; }); expect(content).toBe(HEADER_COLOR); }); test("top tint element is set to header color when header is in view", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.waitForFunction( () => { const el = document.querySelector(".chrome-tint--top"); return el && el.style.backgroundColor !== ""; }, { timeout: 2000 }, ); const bg = await page.evaluate(() => { const el = document.querySelector(".chrome-tint--top"); return el ? el.style.backgroundColor : ""; }); // Browser normalizes hex to rgb() — accept either form. // #032F46 = rgb(3, 47, 70) (with sRGB rounding) expect(bg).toMatch(/^#?032[Ff]46$|^rgb\(\s*3,\s*4[67],\s*70\s*\)$/); }); test("top tint element clears when header scrolls out of view", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.waitForFunction( () => { const el = document.querySelector(".chrome-tint--top"); return el && el.style.backgroundColor !== ""; }, { timeout: 2000 }, ); await page.evaluate(() => window.scrollTo(0, 1200)); await page.waitForFunction( () => { const el = document.querySelector(".chrome-tint--top"); return el && el.style.backgroundColor === ""; }, { timeout: 2000 }, ); const bg = await page.evaluate(() => { const el = document.querySelector(".chrome-tint--top"); return el ? el.style.backgroundColor : ""; }); expect(bg).toBe(""); }); test("theme-color meta clears when header scrolls out of view", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.waitForFunction( (expected) => { const meta = document.querySelector('meta[name="theme-color"]'); return meta && meta.getAttribute("content") === expected; }, HEADER_COLOR, { timeout: 2000 }, ); await page.evaluate(() => window.scrollTo(0, 1200)); await page.waitForTimeout(300); const content = await page.evaluate(() => { const meta = document.querySelector('meta[name="theme-color"]'); return meta ? meta.getAttribute("content") : null; }); expect(content).toBeNull(); }); test("bottom tint element is transparent when footer is out of view", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.waitForTimeout(200); const bg = await page.evaluate(() => { const el = document.querySelector(".chrome-tint--bottom"); return el ? el.style.backgroundColor : ""; }); expect(bg).toBe(""); }); test("bottom tint element is set to footer color when footer enters viewport", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight), ); await page.waitForFunction( () => { const el = document.querySelector(".chrome-tint--bottom"); return el && el.style.backgroundColor !== ""; }, { timeout: 2000 }, ); const bg = await page.evaluate(() => { const el = document.querySelector(".chrome-tint--bottom"); return el ? el.style.backgroundColor : ""; }); // #102C45 = rgb(16, 44, 69) (with sRGB rounding) expect(bg).toMatch(/^#?102[Cc]45$|^rgb\(\s*16,\s*4[34],\s*6[89]\s*\)$/); }); test("bottom tint element clears when footer scrolls out of view", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight), ); await page.waitForFunction( () => { const el = document.querySelector(".chrome-tint--bottom"); return el && el.style.backgroundColor !== ""; }, { timeout: 2000 }, ); await page.evaluate(() => window.scrollTo(0, 0)); await page.waitForFunction( () => { const el = document.querySelector(".chrome-tint--bottom"); return el && el.style.backgroundColor === ""; }, { timeout: 2000 }, ); const bg = await page.evaluate(() => { const el = document.querySelector(".chrome-tint--bottom"); return el ? el.style.backgroundColor : ""; }); expect(bg).toBe(""); }); test("top and bottom tints are independent (split when both elements in view)", async ({ page, }) => { // Short pages (or scrolled-to-the-bottom where the page is short // enough that header and footer overlap the viewport) should // show header color at the top and footer color at the bottom // simultaneously. We simulate this by short-circuiting both // observers to "in view" and verifying both fixed elements // carry the right colors. await page.goto(site, { waitUntil: "networkidle" }); await page.evaluate(() => { // Force both elements to the "in view" state. const header = document.querySelector(".site-header"); const footer = document.querySelector(".site-footer"); // Simulate: the IntersectionObserver would fire for both // if the viewport were tall enough. Directly set the // background via the same DOM API ChromeTint.js uses. document.querySelector(".chrome-tint--top").style.backgroundColor = "#032F46"; document.querySelector(".chrome-tint--bottom").style.backgroundColor = "#102C45"; }); const data = await page.evaluate(() => ({ top: document.querySelector(".chrome-tint--top").style.backgroundColor, bottom: document.querySelector(".chrome-tint--bottom").style.backgroundColor, })); // Both should be set to their respective colors — they don't // share a value. iOS 26 should be able to sample each region // independently. expect(data.top).toMatch(/^#?032[Ff]46$|^rgb\(\s*3,\s*4[67],\s*70\s*\)$/); expect(data.bottom).toMatch(/^#?102[Cc]45$|^rgb\(\s*16,\s*4[34],\s*6[89]\s*\)$/); expect(data.top).not.toBe(data.bottom); }); });