diff --git a/components/download/DownloadExperience.tsx b/components/download/DownloadExperience.tsx index 7fc9b25..c0a52aa 100644 --- a/components/download/DownloadExperience.tsx +++ b/components/download/DownloadExperience.tsx @@ -45,7 +45,7 @@ function OsTabs({ aria-selected={active} onClick={() => onSelect(os)} className={classNames( - "flex flex-1 items-center justify-center gap-2 px-4 py-3 font-display text-sm font-semibold uppercase tracking-label transition-colors", + "flex flex-1 items-center justify-center gap-1.5 px-2 py-3 font-display text-xs font-semibold uppercase tracking-label transition-colors sm:gap-2 sm:px-4 sm:text-sm", "focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset", active ? "border-b-2 border-accent bg-primary/15 text-accent" @@ -54,7 +54,11 @@ function OsTabs({ > {label} - {os === detected && Detected} + {os === detected && ( + + Detected + + )} ); })} diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index 79fe451..e368278 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -7,7 +7,7 @@ import ScreenshotFeed from "./ScreenshotFeed"; export default function Hero() { return (
-
+
{/* Title column */}
@@ -40,8 +40,7 @@ export default function Hero() {
- {/* screenshot feed */} -
+
diff --git a/components/home/RotatingTagline.tsx b/components/home/RotatingTagline.tsx index 429bab2..985f45c 100644 --- a/components/home/RotatingTagline.tsx +++ b/components/home/RotatingTagline.tsx @@ -32,7 +32,7 @@ export default function RotatingTagline() { }, []); return ( -

+

{tagline}

); diff --git a/components/ui/ButtonGroup.tsx b/components/ui/ButtonGroup.tsx index 83e0642..60caac6 100644 --- a/components/ui/ButtonGroup.tsx +++ b/components/ui/ButtonGroup.tsx @@ -15,7 +15,8 @@ export default function ButtonGroup({ className, children, ...rest }: Props) {
*]:flex-1 [&>*]:rounded-none", // keep focus rings visible inside the clipped container diff --git a/cypress/e2e/responsive.cy.ts b/cypress/e2e/responsive.cy.ts new file mode 100644 index 0000000..9e8e793 --- /dev/null +++ b/cypress/e2e/responsive.cy.ts @@ -0,0 +1,124 @@ +/* + * Test if any route has unintended horizontal overflow at most common screens + */ +const WIDTHS = [320, 393, 640, 768, 1024]; + +type Offender = { tag: string; cls: string; left: number; right: number }; + +/** Elements sticking out horizontally at the current viewport width. */ +function findOverflowingElements(win: Window): Offender[] { + const doc = win.document; + const vw = doc.documentElement.clientWidth; + const offenders: Offender[] = []; + + for (const el of Array.from(doc.querySelectorAll("body *"))) { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + if (rect.right <= vw + 1 && rect.left >= -1) continue; + + // Fixed overlays (parallax space, the clown's cage) clip themselves. + if (win.getComputedStyle(el).position === "fixed") continue; + + // Skip elements an ancestor already clips or scrolls horizontally + let ancestor = el.parentElement; + let clipped = false; + while (ancestor && ancestor !== doc.body) { + if (/(hidden|clip|auto|scroll)/.test(win.getComputedStyle(ancestor).overflowX)) { + clipped = true; + break; + } + ancestor = ancestor.parentElement; + } + if (clipped) continue; + + const cls = typeof el.className === "string" ? el.className : ""; + offenders.push({ + tag: el.tagName.toLowerCase(), + cls: cls.slice(0, 100), + left: Math.round(rect.left), + right: Math.round(rect.right), + }); + } + return offenders; +} + +function assertNoHorizontalOverflow(route: string) { + for (const width of WIDTHS) { + cy.viewport(width, 850); + // let the resize propagate and media queries re-evaluate + cy.wait(100); + cy.window().then((win) => { + const offenders = findOverflowingElements(win); + expect( + offenders, + `${route} @ ${width}px elements past the viewport edge:\n` + + JSON.stringify(offenders, null, 2), + ).to.have.length(0); + expect( + win.document.documentElement.scrollWidth, + `${route} @ ${width}px page scroll width`, + ).to.be.at.most(width + 1); + }); + } +} + +describe("Responsive layout", () => { + it("home has no horizontal overflow at any width", () => { + cy.visit("/"); + cy.contains("h1", "Unitystation!").should("be.visible"); + assertNoHorizontalOverflow("/"); + }); + + it("download has no horizontal overflow at any width", () => { + cy.visit("/download"); + cy.contains("h1", "Download Pudu Launcher").should("be.visible"); + assertNoHorizontalOverflow("/download"); + }); + + it("blog has no horizontal overflow at any width", () => { + cy.intercept("GET", /changelog\.unitystation\.org\/posts\/\?page=1$/, { + fixture: "blogPosts-page1.json", + }).as("postsPage1"); + cy.intercept("GET", /changelog\.unitystation\.org\/posts\/\?page=2$/, { + fixture: "blogPosts-page2.json", + }).as("postsPage2"); + + cy.visit("/blog"); + cy.wait("@postsPage1"); + cy.contains("h2", "Station reactor now explodes properly").should("be.visible"); + // materialise the infinite-scroll page before scanning + cy.scrollTo("bottom"); + cy.wait("@postsPage2"); + assertNoHorizontalOverflow("/blog"); + }); + + it("changelog has no horizontal overflow at any width", () => { + cy.intercept("GET", /changelog\.unitystation\.org\/all-changes/, { + fixture: "changelog-page1.json", + }).as("changelog"); + + cy.visit("/changelog"); + cy.wait("@changelog"); + assertNoHorizontalOverflow("/changelog"); + }); + + it("ledger has no horizontal overflow at any width", () => { + cy.intercept("GET", /ledger\.unitystation\.org\/movements\/$/, { + fixture: "ledger-page1.json", + }).as("ledgerPage1"); + + cy.visit("/ledger"); + cy.wait("@ledgerPage1"); + assertNoHorizontalOverflow("/ledger"); + }); + + for (const route of ["/login", "/register", "/reset-password"]) { + it(`${route} has no horizontal overflow at any width`, () => { + cy.visit(route); + cy.get("h1").should("be.visible"); + assertNoHorizontalOverflow(route); + }); + } +}); + +export {}; diff --git a/next.config.js b/next.config.js index a365f31..cf9e4b0 100644 --- a/next.config.js +++ b/next.config.js @@ -2,6 +2,10 @@ module.exports = { reactCompiler: true, + // Dev only: lets phones on the home network open the dev server via the + // machine's LAN IP. Without this Next blocks its /_next dev resources for + // non-localhost origins and pages render but never hydrate (dead buttons). + allowedDevOrigins: ["192.168.1.*"], images: { remotePatterns: [ {