diff --git a/header.php b/header.php index c4e97fe..b43a620 100644 --- a/header.php +++ b/header.php @@ -26,7 +26,10 @@ $showHero = $post->post_parent === $services ? true : false; - + + + diff --git a/static/js/modules/ChromeTint.js b/static/js/modules/ChromeTint.js new file mode 100644 index 0000000..6319208 --- /dev/null +++ b/static/js/modules/ChromeTint.js @@ -0,0 +1,100 @@ +/** + * Chrome Tint + * - + * 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. + * + * - bottom of 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. + * + * 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. + * + * - "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 = '#102C45'; +const THEME_META_NAME = 'theme-color'; +const FOOTER_IN_VIEW_CLASS = 'footer-in-view'; + +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 setTopTint(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) { + document.body.classList.toggle(FOOTER_IN_VIEW_CLASS, active); +} + +function initChromeTint() { + if (typeof IntersectionObserver === 'undefined') return; + + const header = document.querySelector('.site-header'); + const footer = document.querySelector('.site-footer'); + if (!header && !footer) return; + + // 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 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); + } else { + setTopTint(null); + } + // Bottom tint is the footer color ONLY when the footer is in view. + setBottomTint(footerInView); + }; + + 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; diff --git a/static/js/theme.js b/static/js/theme.js index 06f4729..3163c42 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -9,6 +9,7 @@ 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) { @@ -76,6 +77,10 @@ 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); diff --git a/styles/base/global.css b/styles/base/global.css index f2d6b44..f7f85f4 100644 --- a/styles/base/global.css +++ b/styles/base/global.css @@ -70,3 +70,37 @@ 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%; } + +/* + * Bottom chrome tint. + * + * On devices with `viewport-fit=cover` (iOS Safari), the area behind + * the home indicator is part of the page background. The fixed + * pseudo-element below fills the `env(safe-area-inset-bottom)` band + * and shows the footer color when the body has the `.footer-in-view` + * class. That class is toggled by static/js/modules/ChromeTint.js + * when the .site-footer is in view. + * + * No effect on browsers without safe-area-inset-bottom (height resolves + * to 0px and the pseudo-element is invisible). + */ +body::after { + background: var(--color-background); + bottom: 0; + content: ""; + height: env(safe-area-inset-bottom); + left: 0; + pointer-events: none; + position: fixed; + right: 0; + transition: background-color 200ms ease; + z-index: 9999; +} + +body.footer-in-view::after { + background: var(--color-cwc-blue-02); +} + +@media (prefers-reduced-motion: reduce) { + body::after { transition: none; } +} diff --git a/tests/chrome-tint.spec.js b/tests/chrome-tint.spec.js new file mode 100644 index 0000000..40b60b4 --- /dev/null +++ b/tests/chrome-tint.spec.js @@ -0,0 +1,175 @@ +import { expect, test } from "@playwright/test"; + +/** + * Chrome tint behavior: + * + * - Top tint (status bar / notch / Dynamic Island): + * = HEADER_COLOR when the site header is + * in view; cleared (no content) otherwise. + * + * - 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. + * + * Tests run against the local Herd site. + */ + +const site = "http://community-works-collaborative.test/"; + +const HEADER_COLOR = "#032F46"; + +test.describe("chrome tint", () => { + test.use({ viewport: { width: 402, height: 874 } }); + + 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; + }); + // Either the server-rendered value or the value set by JS on + // initial paint (the header is in view at page top) should be + // the header color. + expect(content).toBe(HEADER_COLOR); + }); + + 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("top tint is set when header is in view at page top", async ({ + page, + }) => { + await page.goto(site, { waitUntil: "networkidle" }); + // Wait for ChromeTint.js to run (it observes on DOMContentLoaded). + await page.waitForFunction( + (expected) => { + const meta = document.querySelector('meta[name="theme-color"]'); + return meta && meta.getAttribute("content") === expected; + }, + HEADER_COLOR, + { timeout: 2000 }, + ); + const content = await page.evaluate(() => + document.querySelector('meta[name="theme-color"]').getAttribute("content"), + ); + expect(content).toBe(HEADER_COLOR); + }); + + test("top tint clears when neither header nor footer is in 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"]'); + return meta && meta.getAttribute("content") === expected; + }, + 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("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"), + ); + expect(hasClass).toBe(false); + }); + + test("bottom tint class is added when footer enters the viewport", async ({ + 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 }, + ); + const hasClass = await page.evaluate(() => + document.body.classList.contains("footer-in-view"), + ); + expect(hasClass).toBe(true); + }); + + test("bottom tint class is removed when footer scrolls out of view", async ({ + page, + }) => { + await page.goto(site, { waitUntil: "networkidle" }); + // Scroll to bottom first. + await page.evaluate(() => + window.scrollTo(0, document.documentElement.scrollHeight), + ); + await page.waitForFunction( + () => 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"), + { timeout: 2000 }, + ); + const hasClass = await page.evaluate(() => + document.body.classList.contains("footer-in-view"), + ); + expect(hasClass).toBe(false); + }); + + test("CSS body::after fills the home indicator safe area", async ({ + 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)) { + if (rule.selectorText === "body::after") { + return rule.cssText; + } + } + } catch { + // cross-origin + } + } + return null; + }); + expect(css).toContain("env(safe-area-inset-bottom)"); + expect(css).toContain("position: fixed"); + }); +});