307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
import { expect, test } from "@playwright/test";
|
|
|
|
/**
|
|
* Chrome tint behavior:
|
|
*
|
|
* - Top tint (status bar / notch / Dynamic Island):
|
|
* .chrome-tint--top (a position: fixed <div> 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 <meta name="theme-color"> 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 <div> 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,
|
|
opacity: parseFloat(topCs.opacity),
|
|
},
|
|
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,
|
|
opacity: parseFloat(bottomCs.opacity),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Top element: position fixed, top ≤ 4px, width >= 80%, height >= 12px,
|
|
// opacity 0 (so it never appears as a dark bar over page content).
|
|
expect(layout.top.position).toBe("fixed");
|
|
expect(layout.top.top).toBeLessThanOrEqual(4);
|
|
expect(layout.top.widthPct).toBeGreaterThanOrEqual(80);
|
|
expect(layout.top.height).toBeGreaterThanOrEqual(12);
|
|
expect(layout.top.opacity).toBe(0);
|
|
|
|
// Bottom element: position fixed, within 3px of viewport bottom, width >= 80%,
|
|
// opacity 0.
|
|
expect(layout.bottom.position).toBe("fixed");
|
|
expect(layout.bottom.bottomFromViewport).toBeLessThanOrEqual(3);
|
|
expect(layout.bottom.widthPct).toBeGreaterThanOrEqual(80);
|
|
expect(layout.bottom.opacity).toBe(0);
|
|
});
|
|
|
|
test("fixed chrome tint elements are not visible to the user (no extra dark bars)", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
// Wait for ChromeTint to set the background colors.
|
|
await page.waitForFunction(
|
|
() => {
|
|
const top = document.querySelector(".chrome-tint--top");
|
|
const bottom = document.querySelector(".chrome-tint--bottom");
|
|
return top && bottom &&
|
|
top.style.backgroundColor !== "" &&
|
|
bottom.style.backgroundColor === "";
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
// Even though the elements have background colors, they should
|
|
// be invisible to the user (opacity: 0) so they don't show as
|
|
// dark bars over the page content.
|
|
const opacities = await page.evaluate(() => {
|
|
const top = document.querySelector(".chrome-tint--top");
|
|
const bottom = document.querySelector(".chrome-tint--bottom");
|
|
return {
|
|
top: parseFloat(getComputedStyle(top).opacity),
|
|
bottom: parseFloat(getComputedStyle(bottom).opacity),
|
|
};
|
|
});
|
|
expect(opacities.top).toBe(0);
|
|
expect(opacities.bottom).toBe(0);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|