From ae8a80484f68910b73a0bd807987823de0123280 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Wed, 8 Jul 2026 18:55:19 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9E=20fix:=20Update=20media=20query=20?= =?UTF-8?q?breakpoints=20for=20navigation=20styles=20and=20add=20responsiv?= =?UTF-8?q?e=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- styles/navigation/nav-functional.css | 4 +- styles/navigation/nav-main-default.css | 4 +- tests/header-nav-overflow.spec.js | 121 ++++++++++ tests/responsive-audit.spec.js | 301 +++++++++++++++++++++++++ 4 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 tests/header-nav-overflow.spec.js create mode 100644 tests/responsive-audit.spec.js diff --git a/styles/navigation/nav-functional.css b/styles/navigation/nav-functional.css index 9a4591b..af77452 100644 --- a/styles/navigation/nav-functional.css +++ b/styles/navigation/nav-functional.css @@ -43,7 +43,7 @@ } /* Mobile */ -@media screen and (max-width: 62.5rem) { +@media screen and (max-width: 79.9375rem) { #app:has(.nav-main__toggle[aria-expanded="true"]) { height: calc(100vh - var(--hgtHeader)); } .nav-main__toggle { @@ -81,7 +81,7 @@ } /* Desktop */ -@media screen and (min-width: 62.5rem) { +@media screen and (min-width: 80rem) { .nav-main__toggle { @apply hidden; } diff --git a/styles/navigation/nav-main-default.css b/styles/navigation/nav-main-default.css index 1ff5ae8..ce4914c 100644 --- a/styles/navigation/nav-main-default.css +++ b/styles/navigation/nav-main-default.css @@ -11,7 +11,7 @@ */ /* desktop */ -@media screen and (min-width: 62.5rem) { +@media screen and (min-width: 80rem) { .nav-main .menu-vdi { @apply flex items-center justify-end p-0 m-0 h-full; @@ -67,7 +67,7 @@ } } -@media screen and (min-width: 62.5rem) { +@media screen and (min-width: 80rem) { .menu-vdi { .menu-vdi__toggle { @apply flex items-center gap-2 hover:cursor-pointer; } diff --git a/tests/header-nav-overflow.spec.js b/tests/header-nav-overflow.spec.js new file mode 100644 index 0000000..7316fd9 --- /dev/null +++ b/tests/header-nav-overflow.spec.js @@ -0,0 +1,121 @@ +import { expect, test } from "@playwright/test"; + +/** + * Regression coverage for the 1024-1200px header-nav overflow. + * + * Bug: at viewport widths between 1000 and ~1200, the inline main-navigation + * was wider than the header container, pushing doc.scrollWidth past + * window.innerWidth and producing a horizontal scrollbar. + * + * Fix: the desktop-nav breakpoint moved from 62.5rem (1000px) to 80rem + * (1280px), so the hamburger now covers 1000-1279 and the inline nav + * appears at 1280+. + * + * These tests assert: + * - no horizontal overflow at any of the previously-broken widths + * - the hamburger toggle is visible below 1280 + * - the inline nav is visible at 1280+ + * + * Tests run against the local Herd site where the rebuilt theme.css is + * served. The live deployment (solowebdesigns.net) will pick up the + * rebuilt CSS when the user deploys. + */ + +const site = "http://community-works-collaborative.test/"; + +const widths = [1000, 1023, 1024, 1100, 1200, 1279, 1280, 1366, 1440]; + +for (const w of widths) { + test(`no horizontal overflow at width ${w}`, async ({ browser }) => { + const ctx = await browser.newContext({ + viewport: { width: w, height: 800 }, + }); + const page = await ctx.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const { docW, winW } = await page.evaluate(() => ({ + docW: document.documentElement.scrollWidth, + winW: window.innerWidth, + })); + + expect( + docW, + `viewport ${w}: docW=${docW} exceeds winW=${winW} by ${docW - winW}px`, + ).toBeLessThanOrEqual(winW + 1); + + await ctx.close(); + }); +} + +for (const w of [1000, 1023, 1024, 1100, 1200, 1279]) { + test(`hamburger visible below 1280 at width ${w}`, async ({ browser }) => { + const ctx = await browser.newContext({ + viewport: { width: w, height: 800 }, + }); + const page = await ctx.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const toggleVisible = await page.evaluate(() => { + const t = document.querySelector(".nav-main__toggle"); + if (!t) return false; + const cs = getComputedStyle(t); + const r = t.getBoundingClientRect(); + return ( + cs.display !== "none" && + cs.visibility !== "hidden" && + r.width > 0 && + r.height > 0 + ); + }); + + expect( + toggleVisible, + `width ${w}: hamburger should be visible`, + ).toBe(true); + + await ctx.close(); + }); +} + +for (const w of [1280, 1366, 1440, 1920]) { + test(`inline nav visible at 1280+ at width ${w}`, async ({ browser }) => { + const ctx = await browser.newContext({ + viewport: { width: w, height: 800 }, + }); + const page = await ctx.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const result = await page.evaluate(() => { + const toggle = document.querySelector(".nav-main__toggle"); + const menu = document.querySelector(".nav-main .menu-vdi"); + const nav = document.querySelector(".nav-main"); + return { + toggleDisplay: toggle ? getComputedStyle(toggle).display : null, + menuDisplay: menu ? getComputedStyle(menu).display : null, + navRight: nav ? Math.round(nav.getBoundingClientRect().right) : null, + docW: document.documentElement.scrollWidth, + winW: window.innerWidth, + }; + }); + + // Hamburger hidden + expect( + result.toggleDisplay, + `width ${w}: hamburger should be hidden at 1280+`, + ).toBe("none"); + + // Inline menu visible (display: flex from the @apply) + expect( + result.menuDisplay, + `width ${w}: inline menu should be visible at 1280+`, + ).toBe("flex"); + + // Nav stays inside the viewport + expect( + result.docW, + `width ${w}: docW=${result.docW} exceeds winW=${result.winW}`, + ).toBeLessThanOrEqual(result.winW + 1); + + await ctx.close(); + }); +} diff --git a/tests/responsive-audit.spec.js b/tests/responsive-audit.spec.js new file mode 100644 index 0000000..c4887a2 --- /dev/null +++ b/tests/responsive-audit.spec.js @@ -0,0 +1,301 @@ +import { expect, test } from "@playwright/test"; + +const site = "http://community-works-collaborative.test/"; + +// Viewports to audit (mobile / tablet / desktop / wide) +const viewports = [ + { name: "xs-360", width: 360, height: 800 }, + { name: "sm-402", width: 402, height: 874 }, + { name: "md-768", width: 768, height: 1024 }, + { name: "lg-1024", width: 1024, height: 768 }, + { name: "xl-1280", width: 1280, height: 800 }, + { name: "2xl-1440", width: 1440, height: 900 }, + { name: "3xl-1920", width: 1920, height: 1080 }, +]; + +// Common selectors to audit for overflow / alignment +const selectors = { + header: ".site-header", + logo: ".site-header__logo", + navToggle: ".nav-main__toggle", + main: "main, .site-main, .page-content", + body: "body", + hero: ".homepage-hero, .page-hero", + cards: ".card, .post-card", + sections: "section", + footer: ".site-footer, footer", + grid: ".grid, [class*='grid']", +}; + +// Check for horizontal overflow at every viewport +for (const vp of viewports) { + test(`no horizontal overflow at ${vp.name} (${vp.width}x${vp.height})`, async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + }); + const page = await context.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const overflow = await page.evaluate(() => { + const docWidth = document.documentElement.scrollWidth; + const winWidth = window.innerWidth; + const offenders = []; + + // Find any element wider than the viewport or that pushes the doc to overflow + if (docWidth > winWidth + 1) { + const all = document.querySelectorAll("*"); + for (const el of all) { + const rect = el.getBoundingClientRect(); + if (rect.right > winWidth + 1) { + offenders.push({ + tag: el.tagName.toLowerCase(), + class: el.className?.toString?.().slice(0, 80), + right: Math.round(rect.right), + width: Math.round(rect.width), + }); + if (offenders.length >= 10) break; + } + } + } + + return { docWidth, winWidth, offenders }; + }); + + expect( + overflow.docWidth, + `Doc width ${overflow.docWidth} > viewport ${overflow.winWidth}. Offenders: ${JSON.stringify(overflow.offenders)}`, + ).toBeLessThanOrEqual(overflow.winWidth + 1); + + await context.close(); + }); +} + +// Header alignment per viewport +for (const vp of viewports) { + test(`header alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + }); + const page = await context.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const header = page.locator(selectors.header).first(); + await expect(header).toBeVisible(); + + const box = await header.boundingBox(); + expect(box).not.toBeNull(); + + // Header must start at the left edge (allow 1px sub-pixel) + expect(Math.abs(box.x)).toBeLessThanOrEqual(1); + expect(box.width).toBeGreaterThan(vp.width * 0.8); + + await context.close(); + }); +} + +// Main content padding/alignment per viewport +for (const vp of viewports) { + test(`main content padding at ${vp.name} (${vp.width}x${vp.height})`, async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + }); + const page = await context.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const main = page.locator(selectors.main).first(); + const mainCount = await page.locator(selectors.main).count(); + + if (mainCount > 0) { + const box = await main.boundingBox(); + expect(box).not.toBeNull(); + + // Check for symmetric horizontal padding (allow 1px) + const leftPad = box.x; + const rightPad = vp.width - (box.x + box.width); + expect(Math.abs(leftPad - rightPad)).toBeLessThanOrEqual(2); + } + + await context.close(); + }); +} + +// Section spacing audit +for (const vp of viewports) { + test(`section vertical rhythm at ${vp.name} (${vp.width}x${vp.height})`, async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + }); + const page = await context.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + // Gather all top-level sections and report top/bottom paddings + const sectionInfo = await page.evaluate(() => { + const sections = Array.from( + document.querySelectorAll("body > * section, main > section"), + ); + return sections.slice(0, 15).map((s) => { + const cs = getComputedStyle(s); + const rect = s.getBoundingClientRect(); + return { + class: s.className?.toString?.().slice(0, 60), + top: Math.round(rect.top), + height: Math.round(rect.height), + paddingTop: cs.paddingTop, + paddingBottom: cs.paddingBottom, + marginTop: cs.marginTop, + marginBottom: cs.marginBottom, + }; + }); + }); + + // No section should have 0 vertical padding (visual breathing room) + for (const s of sectionInfo) { + const pt = parseFloat(s.paddingTop) || 0; + const pb = parseFloat(s.paddingBottom) || 0; + // At least one of top/bottom padding should be > 8px for breathing room + // (except narrow edge cases) + if (s.height > 50) { + expect( + Math.max(pt, pb), + `Section "${s.class}" has insufficient vertical padding (pt=${pt}, pb=${pb})`, + ).toBeGreaterThan(8); + } + } + + await context.close(); + }); +} + +// Card grid alignment at common breakpoints +for (const vp of viewports) { + test(`card alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + }); + const page = await context.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const cardCount = await page.locator(selectors.cards).count(); + if (cardCount < 2) { + test.skip(); + return; + } + + const boxes = await page + .locator(selectors.cards) + .evaluateAll((els) => + els + .slice(0, 12) + .map((e) => { + const r = e.getBoundingClientRect(); + return { + x: Math.round(r.x), + y: Math.round(r.y), + w: Math.round(r.width), + h: Math.round(r.height), + }; + }), + ); + + // All visible cards should be within the viewport + for (const b of boxes) { + expect(b.x).toBeGreaterThanOrEqual(0); + expect(b.x + b.w).toBeLessThanOrEqual(vp.width + 1); + } + + // Group by row (similar y) and verify alignment + const rows = {}; + for (const b of boxes) { + const rowKey = Math.round(b.y / 10); // tolerance for sub-row alignment + if (!rows[rowKey]) rows[rowKey] = []; + rows[rowKey].push(b); + } + + for (const row of Object.values(rows)) { + if (row.length < 2) continue; + // Same-row items should share a common left or right edge + // (allow 2px tolerance for grid gap math) + const xs = row.map((b) => b.x); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + // All should be left-aligned to the same x OR span the row + // We just want to ensure they're not wildly misaligned + expect( + maxX - minX, + `Cards in same row misaligned: ${JSON.stringify(row)}`, + ).toBeLessThanOrEqual(vp.width * 0.95); + } + + await context.close(); + }); +} + +// Footer alignment +for (const vp of viewports) { + test(`footer alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + }); + const page = await context.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const footer = page.locator(selectors.footer).first(); + const count = await page.locator(selectors.footer).count(); + if (count === 0) { + test.skip(); + return; + } + + const box = await footer.boundingBox(); + expect(box).not.toBeNull(); + // Footer should not overflow horizontally + expect(box.x + box.width).toBeLessThanOrEqual(vp.width + 1); + + await context.close(); + }); +} + +// Image / media overflow +for (const vp of viewports) { + test(`images fit viewport at ${vp.name} (${vp.width}x${vp.height})`, async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + }); + const page = await context.newPage(); + await page.goto(site, { waitUntil: "networkidle" }); + + const overflows = await page.evaluate(() => { + const imgs = Array.from(document.querySelectorAll("img")); + const out = []; + for (const img of imgs) { + const r = img.getBoundingClientRect(); + if (r.right > window.innerWidth + 1 || r.width > window.innerWidth + 1) { + out.push({ + src: img.src.slice(-60), + width: Math.round(r.width), + right: Math.round(r.right), + }); + } + } + return out; + }); + + expect(overflows.length, `Images overflow: ${JSON.stringify(overflows)}`).toBe(0); + + await context.close(); + }); +}