From 3d4a309e9f21775efbe1017908845c6cc12932f3 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Tue, 6 Oct 2026 12:48:41 -0400 Subject: [PATCH 1/2] Soften page-section nav edge clipping and fades (#232) The horizontal pill row clipped pills flush at the scroll edge - cropping focus outlines at the left edge - and its width-proportional right mask (~15% of the strip) washed out the last visible pill enough to read as disabled. Extend the clip region 12px into the page gutter with negative margins and compensating padding (pills stay gutter-aligned at rest but no longer hard-clip), add top headroom for focus rings, and use scroll-px-3 so keyboard focus-into-view lands at the same inset. Replace the single right-only fade with a fixed 28px mask on each edge: right fade only when content remains, plus a symmetric subtle left fade once scrollLeft exceeds 4px. No mask when everything fits. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- components/navigation/PageSectionNav.tsx | 35 ++++++--- tests/e2e/interactive-content.spec.ts | 37 ++++++++++ tests/integration/page-section-nav.test.tsx | 81 +++++++++++++++++++++ 3 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 tests/integration/page-section-nav.test.tsx diff --git a/components/navigation/PageSectionNav.tsx b/components/navigation/PageSectionNav.tsx index 9ab24d5..9289a3d 100644 --- a/components/navigation/PageSectionNav.tsx +++ b/components/navigation/PageSectionNav.tsx @@ -26,6 +26,7 @@ type PageSectionNavProps = { export function PageSectionNav({ items, className, offset = 96 }: PageSectionNavProps) { const [activeId, setActiveId] = useState(null); const [isSticky, setIsSticky] = useState(false); + const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); const observerRef = useRef(null); const navRef = useRef(null); @@ -85,13 +86,16 @@ export function PageSectionNav({ items, className, offset = 96 }: PageSectionNav }, []); // Single-row strip at every width: overflow scrolls horizontally inside - // the nav, so the right-edge fade only makes sense while content actually - // extends past the visible edge — and lifts when scrolled to the end. + // the nav. Edge fades only make sense where content is actually clipped: + // the right fade lifts when scrolled to the end, and a subtler left + // affordance appears only once pills have scrolled off the start edge. useEffect(() => { const el = containerRef.current; if (!el) return; - const update = () => + const update = () => { setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 4); + setCanScrollLeft(el.scrollLeft > 4); + }; update(); el.addEventListener("scroll", update, { passive: true }); const ro = new ResizeObserver(update); @@ -181,17 +185,26 @@ export function PageSectionNav({ items, className, offset = 96 }: PageSectionNav >
{/* Always one row — overflow scrolls inside the strip at every - width. The right-edge mask fade appears only while content - actually extends past the edge, so a partially visible next pill - reads as an intentional scroll affordance. scroll-px keeps a - comfortable inset when the browser scrolls a focused pill into - view. */} + width. The clip region bleeds 12px into the page gutter + (-mx-3/px-3), so at rest the first pill still aligns with the + content inset while its focus ring and approaching pills get + runway inside the clip; -mt-1/pt-1 gives the same headroom for + focus outlines above the pills. scroll-px-3 makes + focus-into-view land pills at that same inset. Edge fades are a + narrow fixed 28px mask — enough to hint that content continues, + never enough to wash a pill out — applied only at edges that + actually have clipped content. */}
{items.map(({ id, label }) => { diff --git a/tests/e2e/interactive-content.spec.ts b/tests/e2e/interactive-content.spec.ts index 71ae5af..9ee1c1d 100644 --- a/tests/e2e/interactive-content.spec.ts +++ b/tests/e2e/interactive-content.spec.ts @@ -108,6 +108,43 @@ test("section nav stays one scrollable row at every width", async ({ page }) => } }); +// Edge fades are a scroll affordance, not decoration: a narrow fade only at +// edges that actually have clipped content, so pills never wash out at rest. +// Computed maskImage serializes "to right" as 90deg, black as +// rgb(0, 0, 0), and transparent as rgba(0, 0, 0, 0); the first/last stops +// tell which edges fade. +test("section nav fades only edges that have clipped content", async ({ page }) => { + await hydratedGoto(page, "/plan-your-trip"); + // Narrow viewport guarantees the strip overflows. + await page.setViewportSize({ width: 390, height: 800 }); + const row = page.locator('nav[aria-label="On this page"] > div > div'); + + const mask = () => + row.evaluate((el) => getComputedStyle(el).maskImage); + const scrollRow = (x: number) => + row.evaluate((el, v) => { + el.scrollLeft = v; + el.dispatchEvent(new Event("scroll")); + }, x); + + // Start: a right fade hints at more content; the left edge is untouched. + await expect + .poll(mask, "right-only fade at scroll start") + .toMatch(/^linear-gradient\(90deg, rgb\(0, 0, 0\).*rgba\(0, 0, 0, 0\)\)$/); + + // Mid-scroll: both edges fade. + await scrollRow(200); + await expect + .poll(mask, "both edges fade mid-scroll") + .toMatch(/^linear-gradient\(90deg, rgba\(0, 0, 0, 0\).*rgba\(0, 0, 0, 0\)\)$/); + + // End: the right fade lifts; only the left affordance remains. + await scrollRow(9999); + await expect + .poll(mask, "left-only fade at scroll end") + .toMatch(/^linear-gradient\(90deg, rgba\(0, 0, 0, 0\).*rgb\(0, 0, 0\) 28px\)$/); +}); + test("the dive log still renders its UI when Firestore is unreachable", async ({ page }) => { // With the backend fully blocked (fixture default), the SDK resolves from an // empty offline cache — the page must render its empty state rather than diff --git a/tests/integration/page-section-nav.test.tsx b/tests/integration/page-section-nav.test.tsx new file mode 100644 index 0000000..15c8a6b --- /dev/null +++ b/tests/integration/page-section-nav.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PageSectionNav } from "@/components/navigation/PageSectionNav"; + +const ITEMS = Array.from({ length: 10 }, (_, i) => ({ + id: `section-${i}`, + label: `Section ${i}`, +})); + +// jsdom has no layout — scrollWidth/clientWidth are 0 and nothing actually +// clips. Mock the strip's metrics, then fire a scroll event so the +// component recomputes its edge-fade state. +function strip(): HTMLElement { + return document.querySelector( + 'nav[aria-label="On this page"] [class*="overflow-x-auto"]' + ) as HTMLElement; +} + +function setScrollMetrics( + el: HTMLElement, + { scrollWidth = 800, clientWidth = 400, scrollLeft = 0 } = {} +) { + Object.defineProperty(el, "scrollWidth", { configurable: true, value: scrollWidth }); + Object.defineProperty(el, "clientWidth", { configurable: true, value: clientWidth }); + Object.defineProperty(el, "scrollLeft", { + configurable: true, + writable: true, + value: scrollLeft, + }); + fireEvent.scroll(el); +} + +describe("PageSectionNav edge treatment", () => { + it("stays a single scrollable row and keeps pills readable at rest", () => { + render(); + const el = strip(); + expect(el.className).toContain("flex-nowrap"); + expect(el.className).toContain("overflow-x-auto"); + // Inset inside the clip region so pills never sit flush on the clip edge. + expect(el.className).toContain("px-3"); + // Focus scroll-into-view lands pills at that same inset. + expect(el.className).toContain("scroll-px-3"); + + const first = screen.getByRole("button", { name: "Section 0" }); + first.focus(); + expect(first).toHaveFocus(); + }); + + it("shows only a right-edge fade at the start position", () => { + render(); + const el = strip(); + setScrollMetrics(el, { scrollLeft: 0 }); + // Mask present, but the gradient starts opaque — nothing fades left. + expect(el.className).toContain("mask-image:linear-gradient(to_right,black"); + }); + + it("shows both edge affordances while scrolled mid-strip", () => { + render(); + const el = strip(); + setScrollMetrics(el, { scrollLeft: 150 }); + // Gradient opens transparent (left fade) and still closes transparent. + expect(el.className).toContain("to_right,transparent,black"); + expect(el.className).toMatch(/transparent\)\]/); + }); + + it("drops the right fade at the end but keeps the left affordance", () => { + render(); + const el = strip(); + setScrollMetrics(el, { scrollLeft: 400 }); + // Left fade only: opens transparent, closes opaque — nothing fades right. + expect(el.className).toContain("to_right,transparent,black"); + expect(el.className).not.toMatch(/transparent\)\]/); + }); + + it("paints no fades when the strip does not overflow", () => { + render(); + const el = strip(); + setScrollMetrics(el, { scrollWidth: 400, clientWidth: 400 }); + expect(el.className).not.toContain("mask-image"); + }); +}); From 32b611858aaf5c521cf1179046b60e9aef70a79c Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Tue, 6 Oct 2026 13:00:27 -0400 Subject: [PATCH 2/2] Pin nav edge-fade tests to the fixed 28px stop (review) Assert the mask's 28px gradient stop so a regression to a width-proportional fade can't pass silently. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/e2e/interactive-content.spec.ts | 3 +++ tests/integration/page-section-nav.test.tsx | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/e2e/interactive-content.spec.ts b/tests/e2e/interactive-content.spec.ts index 9ee1c1d..a54933f 100644 --- a/tests/e2e/interactive-content.spec.ts +++ b/tests/e2e/interactive-content.spec.ts @@ -128,9 +128,12 @@ test("section nav fades only edges that have clipped content", async ({ page }) }, x); // Start: a right fade hints at more content; the left edge is untouched. + // The 28px stop pins the fade to a fixed narrow band — a regression to a + // width-proportional fade would wash out the last pill again. await expect .poll(mask, "right-only fade at scroll start") .toMatch(/^linear-gradient\(90deg, rgb\(0, 0, 0\).*rgba\(0, 0, 0, 0\)\)$/); + await expect.poll(mask, "fixed 28px fade stop").toContain("28px"); // Mid-scroll: both edges fade. await scrollRow(200); diff --git a/tests/integration/page-section-nav.test.tsx b/tests/integration/page-section-nav.test.tsx index 15c8a6b..122ffd1 100644 --- a/tests/integration/page-section-nav.test.tsx +++ b/tests/integration/page-section-nav.test.tsx @@ -52,6 +52,8 @@ describe("PageSectionNav edge treatment", () => { setScrollMetrics(el, { scrollLeft: 0 }); // Mask present, but the gradient starts opaque — nothing fades left. expect(el.className).toContain("mask-image:linear-gradient(to_right,black"); + // Fixed 28px stop, not a width-proportional fade that washes out pills. + expect(el.className).toContain("black_calc(100%_-_28px)"); }); it("shows both edge affordances while scrolled mid-strip", () => { @@ -61,6 +63,8 @@ describe("PageSectionNav edge treatment", () => { // Gradient opens transparent (left fade) and still closes transparent. expect(el.className).toContain("to_right,transparent,black"); expect(el.className).toMatch(/transparent\)\]/); + // Both fades are the fixed 28px stops. + expect(el.className).toContain("black_28px,black_calc(100%_-_28px)"); }); it("drops the right fade at the end but keeps the left affordance", () => { @@ -68,7 +72,7 @@ describe("PageSectionNav edge treatment", () => { const el = strip(); setScrollMetrics(el, { scrollLeft: 400 }); // Left fade only: opens transparent, closes opaque — nothing fades right. - expect(el.className).toContain("to_right,transparent,black"); + expect(el.className).toContain("to_right,transparent,black_28px"); expect(el.className).not.toMatch(/transparent\)\]/); });