🐞 fix: Remove Chrome tint implementation
This commit is contained in:
-24
@@ -27,9 +27,6 @@ $showHero = $post->post_parent === $services ? true : false;
|
||||
<head>
|
||||
<meta charset="<?php bloginfo( 'charset' ); ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<!-- Status bar tint (top, behind notch/Dynamic Island). Updated at runtime
|
||||
by static/js/modules/ChromeTint.js based on which element is in view. -->
|
||||
<meta name="theme-color" content="#032F46">
|
||||
|
||||
<?php wp_head(); ?>
|
||||
</head>
|
||||
@@ -39,27 +36,6 @@ $showHero = $post->post_parent === $services ? true : false;
|
||||
<?php echo esc_html__( 'Skip to main content' ); ?>
|
||||
</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">
|
||||
<?php get_template_part( 'views/components/nav-aux' ); ?>
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Chrome Tint
|
||||
* -
|
||||
* Tint the browser chrome (status bar + home indicator area) to match
|
||||
* the site header / footer when those elements are in view.
|
||||
*
|
||||
* How it works on each browser:
|
||||
*
|
||||
* - Chrome and iOS ≤ 18:
|
||||
* <meta name="theme-color"> drives the top status bar tint. We
|
||||
* update it dynamically. (Ignored by iOS 26+, but kept for
|
||||
* compatibility with older browsers and Chrome on Android.)
|
||||
*
|
||||
* - iOS 26+ Safari:
|
||||
* The <meta name="theme-color"> tag is ignored. Instead, iOS
|
||||
* samples the background-color of `position: fixed` elements
|
||||
* within 4px of the top or 3px of the bottom of the viewport
|
||||
* (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.
|
||||
*
|
||||
* Importantly, iOS 26 only takes ONE color per region (top OR
|
||||
* bottom), so we can run the two regions independently. This
|
||||
* lets the top go header-color while the bottom is footer-color
|
||||
* when both elements are in view (e.g. on short pages).
|
||||
*
|
||||
* Colors:
|
||||
* - HEADER_COLOR (#032F46) is the darker end of the .site-header
|
||||
* gradient.
|
||||
* - FOOTER_COLOR (#F26B53) is the orange of the .site-footer
|
||||
* `border-bottom` (the secondary color token, --color-cwc-orange-01).
|
||||
* The home indicator area tints to this so the chrome matches
|
||||
* the last visible color when the footer is in view.
|
||||
*
|
||||
* "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).
|
||||
*/
|
||||
|
||||
const HEADER_COLOR = '#032F46';
|
||||
const FOOTER_COLOR = '#F26B53';
|
||||
const THEME_META_NAME = 'theme-color';
|
||||
|
||||
function ensureThemeColorMeta() {
|
||||
let meta = document.head.querySelector(`meta[name="${THEME_META_NAME}"]`);
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('name', THEME_META_NAME);
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function setThemeColor(color) {
|
||||
const meta = ensureThemeColorMeta();
|
||||
if (color == null) {
|
||||
meta.removeAttribute('content');
|
||||
} else {
|
||||
meta.setAttribute('content', color);
|
||||
}
|
||||
}
|
||||
|
||||
function setFixedBackground(el, color) {
|
||||
if (!el) return;
|
||||
if (color == null) {
|
||||
el.style.removeProperty('background-color');
|
||||
} else {
|
||||
el.style.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
|
||||
function initChromeTint() {
|
||||
if (typeof IntersectionObserver === 'undefined') return;
|
||||
|
||||
const header = document.querySelector('.site-header');
|
||||
const footer = document.querySelector('.site-footer');
|
||||
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.
|
||||
let headerInView = false;
|
||||
// Bottom tint state. Truthy means the footer is currently in view.
|
||||
let footerInView = false;
|
||||
|
||||
const apply = () => {
|
||||
// Top tint = header color ONLY when the header is in view.
|
||||
const topColor = headerInView ? HEADER_COLOR : null;
|
||||
setFixedBackground(topEl, topColor);
|
||||
setThemeColor(topColor);
|
||||
|
||||
// Bottom tint = footer color ONLY when the footer is in view.
|
||||
const bottomColor = footerInView ? FOOTER_COLOR : null;
|
||||
setFixedBackground(bottomEl, bottomColor);
|
||||
};
|
||||
|
||||
const buildObserver = (el, onChange) => {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
onChange(entry.isIntersecting);
|
||||
}
|
||||
apply();
|
||||
},
|
||||
// Zero threshold + no rootMargin = "any pixel in view".
|
||||
{ threshold: 0, rootMargin: '0px' },
|
||||
);
|
||||
io.observe(el);
|
||||
return io;
|
||||
};
|
||||
|
||||
if (header) buildObserver(header, (v) => { headerInView = v; });
|
||||
if (footer) buildObserver(footer, (v) => { footerInView = v; });
|
||||
}
|
||||
|
||||
export default initChromeTint;
|
||||
@@ -9,7 +9,6 @@ import tagExternalLinks from './modules/TagExternalLinks.js';
|
||||
import Navigation from './modules/Navigation.js';
|
||||
import initRecentPostsCarousels from './modules/RecentPostsCarousel.js';
|
||||
import { initContactInfoMap } from './modules/ContactInfoMap.js';
|
||||
import initChromeTint from './modules/ChromeTint.js';
|
||||
|
||||
// Add passive event listeners
|
||||
! function (e) {
|
||||
@@ -77,10 +76,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize Contact Info Leaflet maps.
|
||||
initContactInfoMap();
|
||||
|
||||
// Tint the browser chrome (status bar + home indicator) to match
|
||||
// the site header/footer when those elements are in view.
|
||||
initChromeTint();
|
||||
|
||||
// Add Back to Top button to body
|
||||
// const backToTop = document.createElement('x-back-to-top');
|
||||
// document.body.appendChild(backToTop);
|
||||
|
||||
@@ -70,51 +70,3 @@ main#maincontent {
|
||||
/* Responsive embeds */
|
||||
.embed { position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; }
|
||||
.embed iframe, .embed object, .embed embed, .embed video { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
|
||||
/*
|
||||
* Chrome tint elements.
|
||||
*
|
||||
* iOS 26+ Safari samples the background-color of position: fixed
|
||||
* elements within 4px of the top or 3px of the bottom of the viewport
|
||||
* to tint the browser chrome (status bar / home indicator). The
|
||||
* elements below are placed inside those sampling windows and their
|
||||
* backgroundColor is updated at runtime by
|
||||
* static/js/modules/ChromeTint.js.
|
||||
*
|
||||
* opacity: 0 makes the elements fully transparent to the user (so
|
||||
* they don't appear as dark bars over the page content) while the
|
||||
* backgroundColor is still readable by iOS Safari's sampler.
|
||||
*
|
||||
* The elements are kept as small as possible (12px tall) so they
|
||||
* stay well within the iOS safe-area insets. They have no background
|
||||
* by default — they only get a backgroundColor 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.)
|
||||
*/
|
||||
.chrome-tint {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.chrome-tint--top {
|
||||
left: 0;
|
||||
min-height: 12px;
|
||||
top: 4px;
|
||||
/* 4px from top + 12px tall = within the 4px-to-12px iOS 26
|
||||
sampling window for top status bar tinting. */
|
||||
width: 90%;
|
||||
/* width: 90% > 80% iOS minimum for being sampled. */
|
||||
}
|
||||
|
||||
.chrome-tint--bottom {
|
||||
bottom: 3px;
|
||||
/* 3px from bottom = within the 3px iOS sampling window. */
|
||||
left: 0;
|
||||
min-height: 12px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user