🐞 fix: Update Chrome tint implementation for iOS 26+ compatibility and enhance test coverage
This commit is contained in:
@@ -4,24 +4,37 @@
|
|||||||
* Tint the browser chrome (status bar + home indicator area) to match
|
* Tint the browser chrome (status bar + home indicator area) to match
|
||||||
* the site header / footer when those elements are in view.
|
* the site header / footer when those elements are in view.
|
||||||
*
|
*
|
||||||
* - top of screen (status bar / notch / Dynamic Island):
|
* Implementation notes — what each browser honors:
|
||||||
* controlled by the <meta name="theme-color"> tag. iOS Safari
|
|
||||||
* picks up runtime updates.
|
|
||||||
*
|
*
|
||||||
* - bottom of screen (home indicator area, behind the address bar):
|
* - Chrome / older iOS Safari (≤18):
|
||||||
* a fixed pseudo-element filled with `env(safe-area-inset-bottom)`
|
* <meta name="theme-color"> drives the top status bar tint. We
|
||||||
|
* update it dynamically. (Ignored by iOS 26+, kept for compatibility.)
|
||||||
|
*
|
||||||
|
* - iOS 26+ Safari:
|
||||||
|
* The <meta name="theme-color"> tag is ignored. WebKit instead
|
||||||
|
* samples the <body> background-color via a live observer, and
|
||||||
|
* tints the chrome to match. We set body.style.backgroundColor
|
||||||
|
* dynamically to the same color the meta tag would carry.
|
||||||
|
*
|
||||||
|
* - Bottom of the screen (home indicator area, behind the address bar):
|
||||||
|
* A fixed pseudo-element filled with `env(safe-area-inset-bottom)`
|
||||||
* takes its background from a body class that this module toggles
|
* takes its background from a body class that this module toggles
|
||||||
* when the footer is in view.
|
* when the footer is in view. (iOS 26 only samples ONE color for
|
||||||
|
* the entire chrome, so the bottom of the screen will share the
|
||||||
|
* same color as the top — we don't get a true top/bottom split on
|
||||||
|
* iOS 26. The CSS pseudo-element still serves as a no-op fallback
|
||||||
|
* for browsers that do support per-region tinting.)
|
||||||
*
|
*
|
||||||
* The header color (#032F46) is the darker end of the .site-header
|
* Colors:
|
||||||
* gradient. The footer color (#102C45) is the sRGB equivalent of
|
* - HEADER_COLOR (#032F46) is the darker end of the .site-header
|
||||||
* the `--color-cwc-blue-02` token used by .site-footer. If those
|
* gradient.
|
||||||
* colors change in source, update HEADER_COLOR / FOOTER_COLOR below.
|
* - FOOTER_COLOR (#102C45) is the sRGB equivalent of the
|
||||||
|
* --color-cwc-blue-02 token used by .site-footer.
|
||||||
*
|
*
|
||||||
* - "In view" = any pixel of the element overlaps the viewport.
|
* "In view" = any pixel of the element overlaps the viewport.
|
||||||
*
|
*
|
||||||
* No-ops if either the header or footer is missing, or if
|
* No-ops if neither the header nor footer is found, or if
|
||||||
* IntersectionObserver isn't supported (older browsers).
|
* IntersectionObserver isn't supported.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const HEADER_COLOR = '#032F46';
|
const HEADER_COLOR = '#032F46';
|
||||||
@@ -39,17 +52,27 @@ function ensureThemeColorMeta() {
|
|||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTopTint(color) {
|
function setThemeColor(color) {
|
||||||
const meta = ensureThemeColorMeta();
|
const meta = ensureThemeColorMeta();
|
||||||
if (color == null) {
|
if (color == null) {
|
||||||
// Clear: iOS Safari falls back to the page background.
|
|
||||||
meta.removeAttribute('content');
|
meta.removeAttribute('content');
|
||||||
} else {
|
} else {
|
||||||
meta.setAttribute('content', color);
|
meta.setAttribute('content', color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBottomTint(active) {
|
function setBodyBackground(color) {
|
||||||
|
// For iOS 26+: WebKit's live observer on <body> background-color
|
||||||
|
// drives the chrome tint. Removing the inline style lets any CSS
|
||||||
|
// background (or the default transparent) show through.
|
||||||
|
if (color == null) {
|
||||||
|
document.body.style.removeProperty('background-color');
|
||||||
|
} else {
|
||||||
|
document.body.style.backgroundColor = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBottomTintClass(active) {
|
||||||
document.body.classList.toggle(FOOTER_IN_VIEW_CLASS, active);
|
document.body.classList.toggle(FOOTER_IN_VIEW_CLASS, active);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +90,15 @@ function initChromeTint() {
|
|||||||
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
// Top tint is the header color ONLY when the header is in view.
|
// Top tint is the header color ONLY when the header is in view.
|
||||||
// When only the footer is in view, the top tint is cleared (the
|
|
||||||
// user only asked for header-color at the top).
|
|
||||||
if (headerInView) {
|
if (headerInView) {
|
||||||
setTopTint(HEADER_COLOR);
|
setThemeColor(HEADER_COLOR);
|
||||||
|
setBodyBackground(HEADER_COLOR);
|
||||||
} else {
|
} else {
|
||||||
setTopTint(null);
|
setThemeColor(null);
|
||||||
|
setBodyBackground(null);
|
||||||
}
|
}
|
||||||
// Bottom tint is the footer color ONLY when the footer is in view.
|
// Bottom tint is the footer color ONLY when the footer is in view.
|
||||||
setBottomTint(footerInView);
|
setBottomTintClass(footerInView);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildObserver = (el, onChange) => {
|
const buildObserver = (el, onChange) => {
|
||||||
|
|||||||
+44
-20
@@ -3,14 +3,21 @@ import { expect, test } from "@playwright/test";
|
|||||||
/**
|
/**
|
||||||
* Chrome tint behavior:
|
* 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
|
* <meta name="theme-color"> = HEADER_COLOR when the site header is
|
||||||
* in view; cleared (no content) otherwise.
|
* 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):
|
* - Bottom tint (home indicator area, behind the address bar):
|
||||||
* <body class="footer-in-view"> when the site footer is in view;
|
* <body class="footer-in-view"> when the site footer is in view;
|
||||||
* absent otherwise. The CSS body::after pseudo-element picks up
|
* 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.
|
* Tests run against the local Herd site.
|
||||||
*/
|
*/
|
||||||
@@ -45,7 +52,7 @@ test.describe("chrome tint", () => {
|
|||||||
expect(viewport).toContain("viewport-fit=cover");
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(site, { waitUntil: "networkidle" });
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
@@ -64,11 +71,10 @@ test.describe("chrome tint", () => {
|
|||||||
expect(content).toBe(HEADER_COLOR);
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(site, { waitUntil: "networkidle" });
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
// Wait for the initial intersection observation to apply.
|
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(expected) => {
|
(expected) => {
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
@@ -77,24 +83,51 @@ test.describe("chrome tint", () => {
|
|||||||
HEADER_COLOR,
|
HEADER_COLOR,
|
||||||
{ timeout: 2000 },
|
{ 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));
|
await page.evaluate(() => window.scrollTo(0, 1200));
|
||||||
// Give the IntersectionObserver a tick to fire.
|
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const content = await page.evaluate(() => {
|
const content = await page.evaluate(() => {
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
return meta ? meta.getAttribute("content") : null;
|
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();
|
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 ({
|
test("bottom tint class is absent when footer is out of view", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(site, { waitUntil: "networkidle" });
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
// At page top, the footer is not in view.
|
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
const hasClass = await page.evaluate(() =>
|
const hasClass = await page.evaluate(() =>
|
||||||
document.body.classList.contains("footer-in-view"),
|
document.body.classList.contains("footer-in-view"),
|
||||||
@@ -106,11 +139,9 @@ test.describe("chrome tint", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(site, { waitUntil: "networkidle" });
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
// Scroll to the very bottom of the page.
|
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.scrollTo(0, document.documentElement.scrollHeight),
|
window.scrollTo(0, document.documentElement.scrollHeight),
|
||||||
);
|
);
|
||||||
// Wait for the IntersectionObserver to fire.
|
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.body.classList.contains("footer-in-view"),
|
() => document.body.classList.contains("footer-in-view"),
|
||||||
{ timeout: 2000 },
|
{ timeout: 2000 },
|
||||||
@@ -125,7 +156,6 @@ test.describe("chrome tint", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(site, { waitUntil: "networkidle" });
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
// Scroll to bottom first.
|
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.scrollTo(0, document.documentElement.scrollHeight),
|
window.scrollTo(0, document.documentElement.scrollHeight),
|
||||||
);
|
);
|
||||||
@@ -133,7 +163,6 @@ test.describe("chrome tint", () => {
|
|||||||
() => document.body.classList.contains("footer-in-view"),
|
() => document.body.classList.contains("footer-in-view"),
|
||||||
{ timeout: 2000 },
|
{ timeout: 2000 },
|
||||||
);
|
);
|
||||||
// Scroll back up.
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.body.classList.contains("footer-in-view"),
|
() => !document.body.classList.contains("footer-in-view"),
|
||||||
@@ -149,13 +178,7 @@ test.describe("chrome tint", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(site, { waitUntil: "networkidle" });
|
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(() => {
|
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)) {
|
for (const sheet of Array.from(document.styleSheets)) {
|
||||||
try {
|
try {
|
||||||
for (const rule of Array.from(sheet.cssRules)) {
|
for (const rule of Array.from(sheet.cssRules)) {
|
||||||
@@ -173,3 +196,4 @@ test.describe("chrome tint", () => {
|
|||||||
expect(css).toContain("position: fixed");
|
expect(css).toContain("position: fixed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user