🐞 fix: Update media query breakpoints for navigation styles and add responsive tests
This commit is contained in:
@@ -43,7 +43,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* 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)); }
|
#app:has(.nav-main__toggle[aria-expanded="true"]) { height: calc(100vh - var(--hgtHeader)); }
|
||||||
|
|
||||||
.nav-main__toggle {
|
.nav-main__toggle {
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop */
|
/* Desktop */
|
||||||
@media screen and (min-width: 62.5rem) {
|
@media screen and (min-width: 80rem) {
|
||||||
.nav-main__toggle {
|
.nav-main__toggle {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* desktop */
|
/* desktop */
|
||||||
@media screen and (min-width: 62.5rem) {
|
@media screen and (min-width: 80rem) {
|
||||||
.nav-main .menu-vdi {
|
.nav-main .menu-vdi {
|
||||||
@apply flex items-center justify-end p-0 m-0 h-full;
|
@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 {
|
||||||
.menu-vdi__toggle { @apply flex items-center gap-2 hover:cursor-pointer; }
|
.menu-vdi__toggle { @apply flex items-center gap-2 hover:cursor-pointer; }
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user