🐞 fix: Update Chrome tint implementation for iOS 26+ compatibility and enhance test coverage
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 6s

This commit is contained in:
Keith Solomon
2026-07-12 14:12:18 -05:00
parent ca367364f8
commit a41f6f3834
2 changed files with 88 additions and 41 deletions
+44 -20
View File
@@ -3,14 +3,21 @@ import { expect, test } from "@playwright/test";
/**
* Chrome tint behavior:
*
* - Top tint (status bar / notch / Dynamic Island):
* - Top tint (status bar / notch / Dynamic Island) — iOS ≤18 / Chrome:
* <meta name="theme-color"> = 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):
* <body class="footer-in-view"> when the site footer is in view;
* absent otherwise. The CSS body::after pseudo-element picks up
* the footer color from this class.
* 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.
*/
@@ -45,7 +52,7 @@ test.describe("chrome tint", () => {
expect(viewport).toContain("viewport-fit=cover");
});
test("top tint is set when header is in view at page top", async ({
test("theme-color meta is set when header is in view at page top", async ({
page,
}) => {
await page.goto(site, { waitUntil: "networkidle" });
@@ -64,11 +71,10 @@ test.describe("chrome tint", () => {
expect(content).toBe(HEADER_COLOR);
});
test("top tint clears when neither header nor footer is in view", async ({
test("theme-color meta clears when header scrolls out of view", async ({
page,
}) => {
await page.goto(site, { waitUntil: "networkidle" });
// Wait for the initial intersection observation to apply.
await page.waitForFunction(
(expected) => {
const meta = document.querySelector('meta[name="theme-color"]');
@@ -77,24 +83,51 @@ test.describe("chrome tint", () => {
HEADER_COLOR,
{ timeout: 2000 },
);
// Scroll down so the header is out of view and the footer is not yet visible.
await page.evaluate(() => window.scrollTo(0, 1200));
// Give the IntersectionObserver a tick to fire.
await page.waitForTimeout(300);
const content = await page.evaluate(() => {
const meta = document.querySelector('meta[name="theme-color"]');
return meta ? meta.getAttribute("content") : null;
});
// When the header is out of view, the top tint should be cleared
// (no content attribute) so iOS falls back to the page background.
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" });
// At page top, the footer is not in view.
await page.waitForTimeout(200);
const hasClass = await page.evaluate(() =>
document.body.classList.contains("footer-in-view"),
@@ -106,11 +139,9 @@ test.describe("chrome tint", () => {
page,
}) => {
await page.goto(site, { waitUntil: "networkidle" });
// Scroll to the very bottom of the page.
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight),
);
// Wait for the IntersectionObserver to fire.
await page.waitForFunction(
() => document.body.classList.contains("footer-in-view"),
{ timeout: 2000 },
@@ -125,7 +156,6 @@ test.describe("chrome tint", () => {
page,
}) => {
await page.goto(site, { waitUntil: "networkidle" });
// Scroll to bottom first.
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight),
);
@@ -133,7 +163,6 @@ test.describe("chrome tint", () => {
() => document.body.classList.contains("footer-in-view"),
{ timeout: 2000 },
);
// Scroll back up.
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForFunction(
() => !document.body.classList.contains("footer-in-view"),
@@ -149,13 +178,7 @@ test.describe("chrome tint", () => {
page,
}) => {
await page.goto(site, { waitUntil: "networkidle" });
// Verify the body::after pseudo-element has the right CSS
// (env(safe-area-inset-bottom) resolves to 0 on most desktops,
// so we just check that the rule is present and references the
// expected values).
const css = await page.evaluate(() => {
// We can't read pseudo-elements directly, but we can check
// the stylesheet for the rules.
for (const sheet of Array.from(document.styleSheets)) {
try {
for (const rule of Array.from(sheet.cssRules)) {
@@ -173,3 +196,4 @@ test.describe("chrome tint", () => {
expect(css).toContain("position: fixed");
});
});