feature: Add independent chrome tint elements for iOS 26+ compatibility and enhance related tests
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 6s

This commit is contained in:
Keith Solomon
2026-07-12 14:30:41 -05:00
parent a41f6f3834
commit c82e6187af
4 changed files with 248 additions and 156 deletions
+21
View File
@@ -39,6 +39,27 @@ $showHero = $post->post_parent === $services ? true : false;
<?php echo esc_html__( 'Skip to main content' ); ?> <?php echo esc_html__( 'Skip to main content' ); ?>
</a> </a>
<?php
/*
* Chrome tint elements.
*
* iOS 26+ Safari ignores the <meta name="theme-color"> tag and
* instead samples the background-color of fixed/sticky elements
* near the top and bottom of the viewport to tint the browser
* chrome (status bar / home indicator). Their backgroundColor is
* updated at runtime by static/js/modules/ChromeTint.js based on
* which element (site-header or site-footer) is in view.
*
* Sampling windows (iOS 26):
* - top: within 4px of top, width >=80%, min-height 12px
* - bottom: within 3px of bottom, width >=80%, min-height 3px
*
* aria-hidden because these are visual-only and carry no meaning.
*/
?>
<div class="chrome-tint chrome-tint--top" aria-hidden="true"></div>
<div class="chrome-tint chrome-tint--bottom" aria-hidden="true"></div>
<header role="banner" class="site-header bg-secondary flex flex-col items-center justify-start"> <header role="banner" class="site-header bg-secondary flex flex-col items-center justify-start">
<?php get_template_part( 'views/components/nav-aux' ); ?> <?php get_template_part( 'views/components/nav-aux' ); ?>
+32 -38
View File
@@ -4,26 +4,26 @@
* 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.
* *
* Implementation notes — what each browser honors: * How it works on each browser:
* *
* - Chrome / older iOS Safari (≤18): * - Chrome and iOS ≤ 18:
* <meta name="theme-color"> drives the top status bar tint. We * <meta name="theme-color"> drives the top status bar tint. We
* update it dynamically. (Ignored by iOS 26+, kept for compatibility.) * update it dynamically. (Ignored by iOS 26+, but kept for
* compatibility with older browsers and Chrome on Android.)
* *
* - iOS 26+ Safari: * - iOS 26+ Safari:
* The <meta name="theme-color"> tag is ignored. WebKit instead * The <meta name="theme-color"> tag is ignored. Instead, iOS
* samples the <body> background-color via a live observer, and * samples the background-color of `position: fixed` elements
* tints the chrome to match. We set body.style.backgroundColor * within 4px of the top or 3px of the bottom of the viewport
* dynamically to the same color the meta tag would carry. * (at least 80% wide, at least 12px tall) and tints the chrome
* to match. We set the inline `style.backgroundColor` of two
* real fixed <div>s (`.chrome-tint--top` and
* `.chrome-tint--bottom`) declared in header.php.
* *
* - Bottom of the screen (home indicator area, behind the address bar): * Importantly, iOS 26 only takes ONE color per region (top OR
* A fixed pseudo-element filled with `env(safe-area-inset-bottom)` * bottom), so we can run the two regions independently. This
* takes its background from a body class that this module toggles * lets the top go header-color while the bottom is footer-color
* when the footer is in view. (iOS 26 only samples ONE color for * when both elements are in view (e.g. on short pages).
* 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.)
* *
* Colors: * Colors:
* - HEADER_COLOR (#032F46) is the darker end of the .site-header * - HEADER_COLOR (#032F46) is the darker end of the .site-header
@@ -33,14 +33,13 @@
* *
* "In view" = any pixel of the element overlaps the viewport. * "In view" = any pixel of the element overlaps the viewport.
* *
* No-ops if neither the header nor footer is found, or if * No-ops if either the header or footer is missing, or if
* IntersectionObserver isn't supported. * IntersectionObserver isn't supported (older browsers).
*/ */
const HEADER_COLOR = '#032F46'; const HEADER_COLOR = '#032F46';
const FOOTER_COLOR = '#102C45'; const FOOTER_COLOR = '#102C45';
const THEME_META_NAME = 'theme-color'; const THEME_META_NAME = 'theme-color';
const FOOTER_IN_VIEW_CLASS = 'footer-in-view';
function ensureThemeColorMeta() { function ensureThemeColorMeta() {
let meta = document.head.querySelector(`meta[name="${THEME_META_NAME}"]`); let meta = document.head.querySelector(`meta[name="${THEME_META_NAME}"]`);
@@ -61,21 +60,15 @@ function setThemeColor(color) {
} }
} }
function setBodyBackground(color) { function setFixedBackground(el, color) {
// For iOS 26+: WebKit's live observer on <body> background-color if (!el) return;
// drives the chrome tint. Removing the inline style lets any CSS
// background (or the default transparent) show through.
if (color == null) { if (color == null) {
document.body.style.removeProperty('background-color'); el.style.removeProperty('background-color');
} else { } else {
document.body.style.backgroundColor = color; el.style.backgroundColor = color;
} }
} }
function setBottomTintClass(active) {
document.body.classList.toggle(FOOTER_IN_VIEW_CLASS, active);
}
function initChromeTint() { function initChromeTint() {
if (typeof IntersectionObserver === 'undefined') return; if (typeof IntersectionObserver === 'undefined') return;
@@ -83,22 +76,23 @@ function initChromeTint() {
const footer = document.querySelector('.site-footer'); const footer = document.querySelector('.site-footer');
if (!header && !footer) return; if (!header && !footer) return;
const topEl = document.querySelector('.chrome-tint--top');
const bottomEl = document.querySelector('.chrome-tint--bottom');
// Top tint state. Truthy means the header is currently in view. // Top tint state. Truthy means the header is currently in view.
let headerInView = false; let headerInView = false;
// Bottom tint state. Truthy means the footer is currently in view. // Bottom tint state. Truthy means the footer is currently in view.
let footerInView = false; let footerInView = false;
const apply = () => { const apply = () => {
// Top tint is the header color ONLY when the header is in view. // Top tint = header color ONLY when the header is in view.
if (headerInView) { const topColor = headerInView ? HEADER_COLOR : null;
setThemeColor(HEADER_COLOR); setFixedBackground(topEl, topColor);
setBodyBackground(HEADER_COLOR); setThemeColor(topColor);
} else {
setThemeColor(null); // Bottom tint = footer color ONLY when the footer is in view.
setBodyBackground(null); const bottomColor = footerInView ? FOOTER_COLOR : null;
} setFixedBackground(bottomEl, bottomColor);
// Bottom tint is the footer color ONLY when the footer is in view.
setBottomTintClass(footerInView);
}; };
const buildObserver = (el, onChange) => { const buildObserver = (el, onChange) => {
+27 -21
View File
@@ -72,35 +72,41 @@ main#maincontent {
.embed iframe, .embed object, .embed embed, .embed video { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .embed iframe, .embed object, .embed embed, .embed video { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/* /*
* Bottom chrome tint. * Chrome tint elements.
* *
* On devices with `viewport-fit=cover` (iOS Safari), the area behind * iOS 26+ Safari samples the background-color of position: fixed
* the home indicator is part of the page background. The fixed * elements within 4px of the top or 3px of the bottom of the viewport
* pseudo-element below fills the `env(safe-area-inset-bottom)` band * to tint the browser chrome (status bar / home indicator). The
* and shows the footer color when the body has the `.footer-in-view` * elements below are placed inside those sampling windows and their
* class. That class is toggled by static/js/modules/ChromeTint.js * backgroundColor is updated at runtime by
* when the .site-footer is in view. * static/js/modules/ChromeTint.js.
* *
* No effect on browsers without safe-area-inset-bottom (height resolves * The elements have no background by default — they only become
* to 0px and the pseudo-element is invisible). * opaque when the matching element (header / footer) is in view.
* pointer-events: none ensures they never intercept clicks.
*
* (iOS 26 ignores <meta name="theme-color">; this technique is
* what replaced it.)
*/ */
body::after { .chrome-tint {
background: var(--color-background);
bottom: 0;
content: "";
height: env(safe-area-inset-bottom);
left: 0;
pointer-events: none; pointer-events: none;
position: fixed; position: fixed;
right: 0;
transition: background-color 200ms ease;
z-index: 9999; z-index: 9999;
} }
body.footer-in-view::after { .chrome-tint--top {
background: var(--color-cwc-blue-02); left: 0;
min-height: 15px;
top: 4px;
/* 4px from top + 15px tall = within the 4px-to-12px sampling window. */
width: 90%;
/* width: 90% > 80% iOS minimum for being sampled. */
} }
@media (prefers-reduced-motion: reduce) { .chrome-tint--bottom {
body::after { transition: none; } bottom: 3px;
/* 3px from bottom = within the 3px iOS sampling window. */
left: 0;
min-height: 15px;
width: 90%;
} }
+167 -96
View File
@@ -3,21 +3,20 @@ import { expect, test } from "@playwright/test";
/** /**
* Chrome tint behavior: * Chrome tint behavior:
* *
* - Top tint (status bar / notch / Dynamic Island) — iOS ≤18 / Chrome: * - Top tint (status bar / notch / Dynamic Island):
* <meta name="theme-color"> = HEADER_COLOR when the site header is * .chrome-tint--top (a position: fixed <div> near the top of
* in view; cleared (no content) otherwise. * 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.
* *
* - Top tint — iOS 26+ (where the meta tag is ignored): * The <meta name="theme-color"> tag is also updated for Chrome
* document.body.style.backgroundColor is set to the same color. * and iOS ≤18 compatibility.
* 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; * .chrome-tint--bottom (a position: fixed <div> near the bottom
* absent otherwise. The CSS body::after pseudo-element picks up * of the viewport) gets backgroundColor = FOOTER_COLOR when the
* the footer color from this class. (iOS 26 only samples one color * site footer is in view; cleared otherwise.
* 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.
*/ */
@@ -25,22 +24,64 @@ import { expect, test } from "@playwright/test";
const site = "http://community-works-collaborative.test/"; const site = "http://community-works-collaborative.test/";
const HEADER_COLOR = "#032F46"; const HEADER_COLOR = "#032F46";
const FOOTER_COLOR = "#102C45";
test.describe("chrome tint", () => { test.describe("chrome tint", () => {
test.use({ viewport: { width: 402, height: 874 } }); test.use({ viewport: { width: 402, height: 874 } });
test("static theme-color meta is present in the document head", async ({ 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, page,
}) => { }) => {
await page.goto(site, { waitUntil: "networkidle" }); await page.goto(site, { waitUntil: "networkidle" });
const content = await page.evaluate(() => { const layout = await page.evaluate(() => {
const meta = document.querySelector('meta[name="theme-color"]'); const top = document.querySelector(".chrome-tint--top");
return meta ? meta.getAttribute("content") : null; 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,
},
};
}); });
// Either the server-rendered value or the value set by JS on
// initial paint (the header is in view at page top) should be // Top element: position fixed, top ≤ 4px, width >= 80%, height >= 12px.
// the header color. expect(layout.top.position).toBe("fixed");
expect(content).toBe(HEADER_COLOR); 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 }) => { test("viewport meta includes viewport-fit=cover", async ({ page }) => {
@@ -52,23 +93,61 @@ test.describe("chrome tint", () => {
expect(viewport).toContain("viewport-fit=cover"); expect(viewport).toContain("viewport-fit=cover");
}); });
test("theme-color meta is set when header is in view at page top", async ({ test("static theme-color meta is present in the document head", async ({
page, page,
}) => { }) => {
await page.goto(site, { waitUntil: "networkidle" }); await page.goto(site, { waitUntil: "networkidle" });
// Wait for ChromeTint.js to run (it observes on DOMContentLoaded). const content = await page.evaluate(() => {
await page.waitForFunction(
(expected) => {
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
return meta && meta.getAttribute("content") === expected; 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 !== "";
}, },
HEADER_COLOR,
{ timeout: 2000 }, { timeout: 2000 },
); );
const content = await page.evaluate(() => const bg = await page.evaluate(() => {
document.querySelector('meta[name="theme-color"]').getAttribute("content"), 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 },
); );
expect(content).toBe(HEADER_COLOR); 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 ({ test("theme-color meta clears when header scrolls out of view", async ({
@@ -92,50 +171,19 @@ test.describe("chrome tint", () => {
expect(content).toBeNull(); expect(content).toBeNull();
}); });
test("body background is set to header color when header is in view (iOS 26 path)", async ({ test("bottom tint element is transparent when footer is out of view", 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, page,
}) => { }) => {
await page.goto(site, { waitUntil: "networkidle" }); await page.goto(site, { waitUntil: "networkidle" });
await page.waitForTimeout(200); await page.waitForTimeout(200);
const hasClass = await page.evaluate(() => const bg = await page.evaluate(() => {
document.body.classList.contains("footer-in-view"), const el = document.querySelector(".chrome-tint--bottom");
); return el ? el.style.backgroundColor : "";
expect(hasClass).toBe(false); });
expect(bg).toBe("");
}); });
test("bottom tint class is added when footer enters the viewport", async ({ test("bottom tint element is set to footer color when footer enters viewport", async ({
page, page,
}) => { }) => {
await page.goto(site, { waitUntil: "networkidle" }); await page.goto(site, { waitUntil: "networkidle" });
@@ -143,16 +191,21 @@ test.describe("chrome tint", () => {
window.scrollTo(0, document.documentElement.scrollHeight), window.scrollTo(0, document.documentElement.scrollHeight),
); );
await page.waitForFunction( await page.waitForFunction(
() => document.body.classList.contains("footer-in-view"), () => {
const el = document.querySelector(".chrome-tint--bottom");
return el && el.style.backgroundColor !== "";
},
{ timeout: 2000 }, { timeout: 2000 },
); );
const hasClass = await page.evaluate(() => const bg = await page.evaluate(() => {
document.body.classList.contains("footer-in-view"), const el = document.querySelector(".chrome-tint--bottom");
); return el ? el.style.backgroundColor : "";
expect(hasClass).toBe(true); });
// #102C45 = rgb(16, 44, 69) (with sRGB rounding)
expect(bg).toMatch(/^#?102[Cc]45$|^rgb\(\s*16,\s*4[34],\s*6[89]\s*\)$/);
}); });
test("bottom tint class is removed when footer scrolls out of view", async ({ test("bottom tint element clears when footer scrolls out of view", async ({
page, page,
}) => { }) => {
await page.goto(site, { waitUntil: "networkidle" }); await page.goto(site, { waitUntil: "networkidle" });
@@ -160,40 +213,58 @@ test.describe("chrome tint", () => {
window.scrollTo(0, document.documentElement.scrollHeight), window.scrollTo(0, document.documentElement.scrollHeight),
); );
await page.waitForFunction( await page.waitForFunction(
() => document.body.classList.contains("footer-in-view"), () => {
const el = document.querySelector(".chrome-tint--bottom");
return el && el.style.backgroundColor !== "";
},
{ timeout: 2000 }, { timeout: 2000 },
); );
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"), () => {
const el = document.querySelector(".chrome-tint--bottom");
return el && el.style.backgroundColor === "";
},
{ timeout: 2000 }, { timeout: 2000 },
); );
const hasClass = await page.evaluate(() => const bg = await page.evaluate(() => {
document.body.classList.contains("footer-in-view"), const el = document.querySelector(".chrome-tint--bottom");
); return el ? el.style.backgroundColor : "";
expect(hasClass).toBe(false); });
expect(bg).toBe("");
}); });
test("CSS body::after fills the home indicator safe area", async ({ test("top and bottom tints are independent (split when both elements in view)", async ({
page, 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.goto(site, { waitUntil: "networkidle" });
const css = await page.evaluate(() => { await page.evaluate(() => {
for (const sheet of Array.from(document.styleSheets)) { // Force both elements to the "in view" state.
try { const header = document.querySelector(".site-header");
for (const rule of Array.from(sheet.cssRules)) { const footer = document.querySelector(".site-footer");
if (rule.selectorText === "body::after") { // Simulate: the IntersectionObserver would fire for both
return rule.cssText; // 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 =
} catch { "#032F46";
// cross-origin document.querySelector(".chrome-tint--bottom").style.backgroundColor =
} "#102C45";
}
return null;
}); });
expect(css).toContain("env(safe-area-inset-bottom)"); const data = await page.evaluate(() => ({
expect(css).toContain("position: fixed"); 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(/^#?102[Cc]45$|^rgb\(\s*16,\s*4[34],\s*6[89]\s*\)$/);
expect(data.top).not.toBe(data.bottom);
}); });
}); });