diff --git a/apps/docsite/src/__tests__/hero-glow-pinning.test.ts b/apps/docsite/src/__tests__/hero-glow-pinning.test.ts new file mode 100644 index 0000000000000..45f0f6d236c64 --- /dev/null +++ b/apps/docsite/src/__tests__/hero-glow-pinning.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file hero-glow-pinning.test.ts + * @input The home hero's HeroThemeReel source + * @output Guards both halves of the aurora glow's contract + * @position Regression test for the fidelity regression #5415 shipped, and for + * the #5392 property it must keep + * + * The glow has to satisfy two things at once, and #5415 traded one for the + * other in each direction: + * + * 1. It must stay PINNED while the hero is on screen. The blobs sit low in + * its 1050px box, so a glow that scrolls with the hero walks them up + * through the viewport and visibly warms the page mid-scroll. + * 2. It must be BOUNDED, so it can't paint into the strip an overscroll + * opens past the end of the page — the exposure that had the whole site + * suppressing overscroll, which costs pull-to-refresh (#5392). + * + * `position: fixed` gives (1) but not (2); an ordinary in-flow `absolute` + * gives (2) but not (1). A sticky, zero-height layer with the glow absolute + * inside it gives both, which is why the structural assertion below matters as + * much as the property one. + */ + +import {describe, it, expect} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SOURCE = fs.readFileSync( + path.join( + HERE, + '..', + 'app', + '(site)', + '_landing', + 'hero', + 'HeroThemeReel.tsx', + ), + 'utf8', +); + +/** The `{...}` body of one `stylex.create` entry, e.g. `backdropGlow`. */ +function styleBlock(name: string): string { + const start = SOURCE.indexOf(`${name}: {`); + expect(start, `no \`${name}\` style in the source`).toBeGreaterThan(-1); + const open = SOURCE.indexOf('{', start); + let depth = 0; + for (let i = open; i < SOURCE.length; i++) { + if (SOURCE[i] === '{') { + depth++; + } else if (SOURCE[i] === '}' && --depth === 0) { + return SOURCE.slice(open, i + 1); + } + } + throw new Error(`unbalanced braces in \`${name}\``); +} + +/** The value a StyleX property takes with no media query in play. */ +function narrowValue(block: string, property: string): string { + const at = block.indexOf(`${property}: `); + expect(at, `no \`${property}\` declaration`).toBeGreaterThan(-1); + const rest = block.slice(at + property.length + 2); + if (!rest.startsWith('{')) { + return rest.slice(0, rest.search(/[,\n]/)).trim(); + } + const arm = /default:\s*([^,\n]+)/.exec(rest.slice(0, rest.indexOf('}'))); + expect(arm, `\`${property}\` has no default arm`).not.toBeNull(); + return arm![1].trim(); +} + +describe('home hero — the aurora glow', () => { + it('is bounded at narrow widths, so overscroll can stay enabled (#5392)', () => { + // Unbounded === `fixed`: glued to the viewport for the whole document + // scroll, so still painting in the gap at the end of the page. + expect(narrowValue(styleBlock('backdropGlow'), 'position')).not.toBe( + "'fixed'", + ); + }); + + it('is still pinned at narrow widths, not scrolling with the hero (regressed by #5415)', () => { + // Bounded is not enough on its own — an `absolute` glow parked in ordinary + // flow scrolls away. It has to be absolute against the STICKY layer, which + // is what keeps it visually pinned. Assert the containment structurally: + // the glow's element must be rendered inside the element carrying + // `pinLayer`, and `pinLayer` must actually be sticky. + expect(narrowValue(styleBlock('pinLayer'), 'position')).toBe("'sticky'"); + expect(narrowValue(styleBlock('pinLayer'), 'height')).toBe('0'); + + // Slice out the pin layer's JSX children by indentation: the source is + // prettier-formatted, so the element's closing tag sits at the same column + // its opening tag does. (Tag-depth counting is the obvious alternative and + // gets this wrong — the glow is self-closing and never emits a ``.) + const lines = SOURCE.split('\n'); + const open = lines.findIndex(l => + l.includes('stylex.props(styles.pinLayer)'), + ); + expect(open, 'nothing renders `pinLayer`').toBeGreaterThan(-1); + const indent = lines[open].length - lines[open].trimStart().length; + const close = lines.findIndex( + (l, i) => i > open && l === `${' '.repeat(indent)}`, + ); + expect(close, 'could not find the pin layer’s closing tag').toBeGreaterThan( + -1, + ); + + expect(lines.slice(open + 1, close).join('\n')).toContain( + 'styles.backdropGlow', + ); + }); +}); diff --git a/apps/docsite/src/app/(site)/_landing/hero/HeroThemeReel.tsx b/apps/docsite/src/app/(site)/_landing/hero/HeroThemeReel.tsx index 896939d6299ed..d73669d69124b 100644 --- a/apps/docsite/src/app/(site)/_landing/hero/HeroThemeReel.tsx +++ b/apps/docsite/src/app/(site)/_landing/hero/HeroThemeReel.tsx @@ -123,9 +123,17 @@ const styles = stylex.create({ }, height: 'auto', }, - // Sticky, zero-height layer hosting the overlap cards so they pin with the - // hero and don't intercept clicks. - cardsLayer: { + // The hero's pinned layer: the aurora glow rides here with the overlap + // cards, so both pin with the hero and don't intercept clicks. + // + // Sticky, not fixed. Sticky pins to the viewport exactly like fixed but is + // bounded by its container, so the layer stops existing on screen once the + // hero band has scrolled by and can never paint into the strip an overscroll + // opens past the end of the page. Zero height is what makes that work: a + // sticky box only stays pinned for the slack between its own height and its + // container's, so a zero-height layer gets the whole band to travel, and the + // visuals inside it are positioned absolutely against it. + pinLayer: { position: 'sticky', top: 'var(--appshell-header-height, 0px)', height: 0, @@ -169,26 +177,24 @@ const styles = stylex.create({ zIndex: 0, }, // Blurred aurora glow — in the same 1200px box as the cards so blobs and - // cards stay aligned; pinned at >=1024px and scrolling away with the hero - // below that (see `position`). Capped to 100vw to avoid horizontal scroll. - // Blob centers sit under the card clusters; colors come from --aurora-* per - // slide. + // cards stay aligned; it rides pinLayer with them. Capped to 100vw to avoid + // horizontal scroll. Blob centers sit under the card clusters; colors come + // from --aurora-* per slide. backdropGlow: { // Desktop: fixed, part of the pin-and-cover effect alongside heroContent - // and the cards stage. Narrow: absolute within heroScope (position: - // relative), so it scrolls away with the hero instead of staying pinned - // for the whole page — a fixed glow below 1024px reached past the footer - // into the bottom-overscroll gap. That exposure is what the app-global - // `overscroll-behavior-y: none` in globals.css was suppressing, at the - // cost of pull-to-refresh on every route on mobile; bounding the glow - // here is what lets that rule scope to desktop widths (#5392). + // and the cards stage. Narrow: absolute against pinLayer, which is sticky + // and so pins this exactly as `fixed` did while the hero is on screen — + // but bounded, so the glow can't reach past the footer into the + // bottom-overscroll gap. That exposure is what the app-global + // `overscroll-behavior-y: none` was suppressing at the cost of + // pull-to-refresh on every mobile route; bounding the glow is what lets + // the rule scope to desktop widths (#5392). position: { default: 'absolute', '@media (min-width: 1024px)': 'fixed', }, - // heroScope already starts below the header (it's the sibling after - // navBackdrop in document flow), so the absolute case needs no offset; - // only the fixed case has to clear the header itself. + // pinLayer already carries the header offset, so the absolute case needs + // none; only the fixed case has to clear the header itself. top: { default: 0, '@media (min-width: 1024px)': 'var(--appshell-header-height, 0px)', @@ -451,19 +457,19 @@ export function HeroReelCards() {