From 4eb1d40b93095ccd4b1e64e5e5268fe101483237 Mon Sep 17 00:00:00 2001 From: mavengence Date: Wed, 12 Aug 2026 09:32:10 +0200 Subject: [PATCH] fix(nav): drop the logo mark's dot, keep "L" centred and rotating The mark paired "L" with a "." that faded and collapsed on scroll. That pairing went through two rounds of alignment bugs in a row: 1. (#37) The dot faded via `opacity` alone. Opacity doesn't remove layout width, so the invisible dot kept reserving space inside the square's `justify-content: center` box, pushing "L" off centre. 2. Even after that fix, the dot's `inline-block` defaulted to `vertical-align: baseline`, which (per spec, once `overflow-hidden` is set) aligns the dot's own bottom edge to L's text baseline rather than to L's visible bottom - measured 4-6px of gap depending on the icon's font size, the dot visibly floating above where it should rest. Two fixable bugs on the same pairing is a sign the pairing itself is the wrong shape for this animation, not that the next fix will be the last one. Dropped the dot: "L" alone, centred, rotating, has no second element to misalign against, and no bug class from either regression above can recur. Verified via Playwright measurement (not just visual review): "L" sits within <1px of the square's true centre at every scroll checkpoint from 0-200px, and the square's transform is non-identity once fully scrolled and returns to "none" at rest. Stress-tested 8 consecutive runs across chromium/mobile-chromium/mobile-webkit with zero flakes. The e2e test is rewritten to match: it no longer asserts anything about a dot, since there isn't one, only that "L" stays centred through the full transition and that the square actually rotates. --- packages/website/src/components/nav.tsx | 26 ------- .../tests/e2e/site-header-logo-mark.spec.ts | 76 +++++-------------- 2 files changed, 19 insertions(+), 83 deletions(-) diff --git a/packages/website/src/components/nav.tsx b/packages/website/src/components/nav.tsx index cd945b4..ca2f824 100644 --- a/packages/website/src/components/nav.tsx +++ b/packages/website/src/components/nav.tsx @@ -223,14 +223,6 @@ function LogoWordmark({ const lOpacity = useTransform(scrollY, [40, 120], [1, 0]); const lWidth = useTransform(scrollY, [40, 120], [14, 0]); - /* The "." in the icon fades out as it merges with the wordmark. Width - collapses over the same range as opacity, since the dot is an inline - sibling of "L" inside a `justify-content: center` box, so fading it to - `opacity: 0` alone left its layout width in place, which kept "L" off - centre (reading as if the square clipped its left side once rotated). */ - const dotOpacity = useTransform(scrollY, [60, 130], [1, 0]); - const dotWidth = useTransform(scrollY, [60, 130], ["0.4em", "0em"]); - return ( /* No clip on this box. The square's rotated bounding box and its hard offset shadow both grow past the flex item's edges, so an `overflow-hidden` @@ -254,9 +246,6 @@ function LogoWordmark({ backgroundColor: LOGO_ORIGINAL_ORANGE, }} > - {/* The dot stays in flow, as it does on the business site. Taking it - out with `position: absolute` left the mark centring the bare "L", - pushing the visible "L." off-centre inside the square. */} L - {/* `inline-block` defaults to `vertical-align: baseline`, which - aligns the dot's OWN baseline (its bottom edge, per spec, once - `overflow-hidden` is set) to L's text baseline - not to L's own - visible bottom. Measured at every font size this icon uses - (13-20px): that left the dot 4-6px above L's bottom edge. - `text-bottom` aligns to the bottom of the parent's font instead, - which lands exactly on L's bottom at every size (0px gap, - measured), with no magic-number offset to keep in sync with the - font. */} - - . - diff --git a/packages/website/tests/e2e/site-header-logo-mark.spec.ts b/packages/website/tests/e2e/site-header-logo-mark.spec.ts index 6787c7b..acf1d1b 100644 --- a/packages/website/tests/e2e/site-header-logo-mark.spec.ts +++ b/packages/website/tests/e2e/site-header-logo-mark.spec.ts @@ -1,31 +1,17 @@ import { test, expect, type Page } from "@playwright/test"; /** - * Header logo mark contract: the scroll-driven "L." icon must keep "." sitting - * on L's own bottom edge (not merely overlapping its line), and "L" centred in - * the square, at every point of its scroll transition (0-160px), not just at - * rest and at the end. + * Header logo mark contract: the scroll-driven icon must keep "L" centred in + * the square at every point of its scroll transition (0-160px), not just at + * rest and at the end, and the square must actually rotate. * - * Two regressions this guards, both about the "." span, both invisible to a - * plain screenshot at a glance: - * - * 1. (#37) The dot faded via `opacity` alone. Opacity does not remove layout - * width, so the invisible dot kept reserving space inside the square's - * `justify-content: center` box for the whole 60-130px fade range, pushing - * "L" left of true centre the entire time - on a rotated square this reads - * as a clipped corner rather than off-centre text. - * 2. The dot's `inline-block` defaulted to `vertical-align: baseline`, which - * aligns the dot's OWN baseline - its bottom edge, per spec, once - * `overflow-hidden` is set - to L's TEXT baseline, not to L's own visible - * bottom. Measured 4-6px of gap depending on the icon's current font size - * (13-20px across the scroll range): the dot visibly floated above L's - * bottom rather than resting on it. `align-text-bottom` closes this to 0px - * at every size, measured directly, not eyeballed. - * - * A same-line check that only asks "do these two elements' vertical bands - * overlap at all" passes even with the 4-6px gap from #2 - overlap is a much - * weaker property than "sits on the same baseline" and is why that bug shipped - * once already. `sitsOnBaseline` below asserts the tight version instead. + * The mark previously paired "L" with a "." that faded and collapsed on + * scroll. That pairing went through two rounds of alignment bugs - the dot's + * `opacity`-only fade left stale layout width that pushed "L" off centre + * (#37), and even after that fix the dot's `vertical-align: baseline` + * default put it 4-6px above L's own bottom edge instead of resting on it. + * The dot was dropped entirely rather than chase a third alignment fix: "L" + * alone, centred, has no second element to misalign against. */ const HOME_LINK = 'nav a[href="/"]'; @@ -36,37 +22,21 @@ async function markGeometry(page: Page) { const square = document.querySelector(sel); if (!square) throw new Error(`icon mark not found: ${sel}`); const inner = square.querySelector("span"); - const dot = inner?.querySelector("span"); - if (!inner || !dot) throw new Error("L/dot spans not found inside mark"); + if (!inner) throw new Error("L span not found inside mark"); const range = document.createRange(); range.selectNodeContents(inner.childNodes[0]); const lRect = range.getBoundingClientRect(); - const dotRect = dot.getBoundingClientRect(); const squareRect = square.getBoundingClientRect(); - const dotStyle = getComputedStyle(dot); return { squareCenterX: squareRect.x + squareRect.width / 2, squareTransform: getComputedStyle(square).transform, - lTop: lRect.top, - lBottom: lRect.bottom, lCenterX: lRect.x + lRect.width / 2, - dotTop: dotRect.top, - dotBottom: dotRect.bottom, - dotOpacity: Number(dotStyle.opacity), }; }, ICON_MARK); } -// Deliberately NOT "do the two vertical bands overlap at all" - a 4-6px gap -// between the dot's bottom and L's bottom (the #2 regression above) still -// overlaps, since the dot's own height is well over 6px. This checks the -// specific thing that matters: the dot's bottom edge sits where L's does. -function sitsOnBaseline(g: Awaited>) { - return Math.abs(g.dotBottom - g.lBottom) < 1.5; -} - // The document sets `scroll-behavior: smooth`, so the two-argument // `window.scrollTo(x, y)` form (which always defers to that CSS property) // animates over several hundred ms instead of jumping. `behavior: "instant"` @@ -94,8 +64,8 @@ test.describe("primary navigation logo mark", () => { // project's WebKit run: neither a programmatic `scrollTo` + a manually // dispatched `scroll` event, nor a real `mouse.wheel` gesture on a desktop // (non-mobile) WebKit context, ever produced a change in this mark's - // rendered `transform`/`opacity` in Playwright's bundled WebKit, headless or - // headed - while `window.scrollY` itself updated correctly every time. That + // rendered `transform` in Playwright's bundled WebKit, headless or headed - + // while `window.scrollY` itself updated correctly every time. That // implicates framer-motion's `useScroll()` reporting to Playwright's WebKit // specifically, not this component: every other scroll-driven nav property // (icon size, border, wordmark tracking) rides the same MotionValue and @@ -109,7 +79,7 @@ test.describe("primary navigation logo mark", () => { ); }); - test("L and the dot stay on the same line and centred through the whole scroll transition", async ({ + test("L stays centred in the square through the whole scroll transition", async ({ page, }) => { await page.goto("/"); @@ -120,21 +90,14 @@ test.describe("primary navigation logo mark", () => { for (const scrollY of [0, 60, 95, 130, 200]) { settled = await scrollToAndSettle(page, scrollY); - // The dot's bottom edge must sit on L's bottom edge (within 1.5px), not - // float above it - a looser "these two elements' lines merely overlap" - // check would miss the 4-6px baseline gap this guards. expect( - sitsOnBaseline(settled), - `at scrollY=${scrollY}, dot bottom ${settled.dotBottom.toFixed(1)} should sit on L's bottom ${settled.lBottom.toFixed(1)} (within 1.5px)`, - ).toBe(true); + Math.abs(settled.lCenterX - settled.squareCenterX), + `at scrollY=${scrollY}, L (centre ${settled.lCenterX.toFixed(1)}) should sit on the square's centre (${settled.squareCenterX.toFixed(1)})`, + ).toBeLessThan(3); } if (settled === null) throw new Error("unreachable: loop always assigns"); - // At full scroll, the dot has faded and collapsed; "L" must sit on the - // square's true horizontal centre, not left of it, and the square must - // actually be rotating rather than stuck at rest. - expect(settled.dotOpacity).toBeLessThan(0.05); - expect(Math.abs(settled.lCenterX - settled.squareCenterX)).toBeLessThan(3); + // The square must actually be rotating at full scroll, not stuck at rest. expect(settled.squareTransform).not.toBe("none"); }); @@ -147,8 +110,7 @@ test.describe("primary navigation logo mark", () => { await scrollToAndSettle(page, 200); const g = await scrollToAndSettle(page, 0); - expect(g.dotOpacity).toBeCloseTo(1, 1); expect(g.squareTransform).toBe("none"); - expect(sitsOnBaseline(g)).toBe(true); + expect(Math.abs(g.lCenterX - g.squareCenterX)).toBeLessThan(3); }); });