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 = "#F26B53";
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 : "";
});
// #F26B53 = rgb(242, 107, 83) (no rounding)
expect(bg).toMatch(/^#?F26B53$|^rgb\(\s*242,\s*107,\s*83\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 =
"#F26B53";
});
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(/^#?F26B53$|^rgb\(\s*242,\s*107,\s*83\s*\)$/);
expect(data.top).not.toBe(data.bottom);
});
});