diff --git a/static/js/modules/ChromeTint.js b/static/js/modules/ChromeTint.js index 6319208..a6be1d4 100644 --- a/static/js/modules/ChromeTint.js +++ b/static/js/modules/ChromeTint.js @@ -4,24 +4,37 @@ * Tint the browser chrome (status bar + home indicator area) to match * the site header / footer when those elements are in view. * - * - top of screen (status bar / notch / Dynamic Island): - * controlled by the tag. iOS Safari - * picks up runtime updates. + * Implementation notes — what each browser honors: * - * - bottom of screen (home indicator area, behind the address bar): - * a fixed pseudo-element filled with `env(safe-area-inset-bottom)` + * - Chrome / older iOS Safari (≤18): + * drives the top status bar tint. We + * update it dynamically. (Ignored by iOS 26+, kept for compatibility.) + * + * - iOS 26+ Safari: + * The tag is ignored. WebKit instead + * samples the 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 - * 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 - * gradient. The footer color (#102C45) is the sRGB equivalent of - * the `--color-cwc-blue-02` token used by .site-footer. If those - * colors change in source, update HEADER_COLOR / FOOTER_COLOR below. + * Colors: + * - HEADER_COLOR (#032F46) is the darker end of the .site-header + * gradient. + * - 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 - * IntersectionObserver isn't supported (older browsers). + * No-ops if neither the header nor footer is found, or if + * IntersectionObserver isn't supported. */ const HEADER_COLOR = '#032F46'; @@ -39,17 +52,27 @@ function ensureThemeColorMeta() { return meta; } -function setTopTint(color) { +function setThemeColor(color) { const meta = ensureThemeColorMeta(); if (color == null) { - // Clear: iOS Safari falls back to the page background. meta.removeAttribute('content'); } else { meta.setAttribute('content', color); } } -function setBottomTint(active) { +function setBodyBackground(color) { + // For iOS 26+: WebKit's live observer on 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); } @@ -67,15 +90,15 @@ function initChromeTint() { const apply = () => { // 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) { - setTopTint(HEADER_COLOR); + setThemeColor(HEADER_COLOR); + setBodyBackground(HEADER_COLOR); } else { - setTopTint(null); + setThemeColor(null); + setBodyBackground(null); } // Bottom tint is the footer color ONLY when the footer is in view. - setBottomTint(footerInView); + setBottomTintClass(footerInView); }; const buildObserver = (el, onChange) => { diff --git a/tests/chrome-tint.spec.js b/tests/chrome-tint.spec.js index 40b60b4..60af9df 100644 --- a/tests/chrome-tint.spec.js +++ b/tests/chrome-tint.spec.js @@ -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: * = 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. + * 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"); }); }); +