import { expect, test } from "@playwright/test"; /** * Chrome tint behavior: * * - Top tint (status bar / notch / Dynamic Island) — iOS ≤18 / Chrome: * = HEADER_COLOR when the site header is * in view; cleared (no content) otherwise. * * - Top tint — iOS 26+ (where the meta tag is ignored): * document.body.style.backgroundColor is set to the same color. * WebKit's live observer picks up body background changes and tints * the chrome to match. * * - Bottom tint (home indicator area, behind the address bar): * when the site footer is in view; * absent otherwise. The CSS body::after pseudo-element picks up * the footer color from this class. (iOS 26 only samples one color * for the entire chrome, so the top and bottom share a color on * iOS 26; the pseudo-element is a no-op fallback there.) * * Tests run against the local Herd site. */ const site = "http://community-works-collaborative.test/"; const HEADER_COLOR = "#032F46"; test.describe("chrome tint", () => { test.use({ viewport: { width: 402, height: 874 } }); 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; }); // Either the server-rendered value or the value set by JS on // initial paint (the header is in view at page top) should be // the header color. expect(content).toBe(HEADER_COLOR); }); 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("theme-color meta is set when header is in view at page top", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); // Wait for ChromeTint.js to run (it observes on DOMContentLoaded). await page.waitForFunction( (expected) => { const meta = document.querySelector('meta[name="theme-color"]'); return meta && meta.getAttribute("content") === expected; }, HEADER_COLOR, { timeout: 2000 }, ); const content = await page.evaluate(() => document.querySelector('meta[name="theme-color"]').getAttribute("content"), ); expect(content).toBe(HEADER_COLOR); }); 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("body background is set to header color when header is in view (iOS 26 path)", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); // Wait for ChromeTint.js to set the body background. await page.waitForFunction( () => document.body.style.backgroundColor !== "", { timeout: 2000 }, ); const bg = await page.evaluate(() => document.body.style.backgroundColor); // The 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("body background clears when header scrolls out of view", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.waitForFunction( () => document.body.style.backgroundColor !== "", { timeout: 2000 }, ); await page.evaluate(() => window.scrollTo(0, 1200)); await page.waitForFunction( () => document.body.style.backgroundColor === "", { timeout: 2000 }, ); const bg = await page.evaluate(() => document.body.style.backgroundColor); expect(bg).toBe(""); }); test("bottom tint class is absent when footer is out of view", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.waitForTimeout(200); const hasClass = await page.evaluate(() => document.body.classList.contains("footer-in-view"), ); expect(hasClass).toBe(false); }); test("bottom tint class is added when footer enters the viewport", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight), ); await page.waitForFunction( () => document.body.classList.contains("footer-in-view"), { timeout: 2000 }, ); const hasClass = await page.evaluate(() => document.body.classList.contains("footer-in-view"), ); expect(hasClass).toBe(true); }); test("bottom tint class is removed 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( () => document.body.classList.contains("footer-in-view"), { timeout: 2000 }, ); await page.evaluate(() => window.scrollTo(0, 0)); await page.waitForFunction( () => !document.body.classList.contains("footer-in-view"), { timeout: 2000 }, ); const hasClass = await page.evaluate(() => document.body.classList.contains("footer-in-view"), ); expect(hasClass).toBe(false); }); test("CSS body::after fills the home indicator safe area", async ({ page, }) => { await page.goto(site, { waitUntil: "networkidle" }); const css = await page.evaluate(() => { for (const sheet of Array.from(document.styleSheets)) { try { for (const rule of Array.from(sheet.cssRules)) { if (rule.selectorText === "body::after") { return rule.cssText; } } } catch { // cross-origin } } return null; }); expect(css).toContain("env(safe-area-inset-bottom)"); expect(css).toContain("position: fixed"); }); });