From a6f3c81f74b7593d88eef99d1061d4a13bb677ca Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:34:51 -0400 Subject: [PATCH 1/3] fix(docsite): pin the hero with sticky rails Contain the desktop-pinned hero layers so the app-global `overscroll-behavior-y: none` can be deleted outright instead of gated to desktop widths: macOS trackpads get their native rubber-band back. A `fixed` layer is not part of the document, so when the document rubber-bands past its own edge the layer sits in the exposed gap. A `sticky` layer lifts with the document and cannot paint past its containing block. Each pinned layer (aurora backdrop, overlap cards, hero text) now rides its own `absolute; inset: 0` rail, a direct child of heroScope spanning hero band + showcase and placed ahead of the showcase overlay, as a `position: sticky` box at >=1024px. Centering moves from `left: 50%` + `translateX(-50%)` to auto margins inside the full-width rail, and the 1200px box is capped to the rail's width rather than 100vw: with a classic scrollbar 100vw is wider than the rail, which zeroes the auto margins and shoves the box left. A rail inside the 760px band alone would release the layer after ~48px, which is why the rails span the showcase too. HeroReelProvider is context-only now; its hover/focus/touch surface is the new HeroReelSwipeArea, which page.tsx uses as the hero text's rail so the band still pauses the reel and the mobile collage still swipes. Also records on LayoutContent why `overflow: clip` must stay `clip`: `hidden` or `auto` there would silently un-pin the landing page. A source-invariant test (the docsite suite is node-only) guards the rule's absence, the three layers never going back to `fixed`, the rails' order and containment, and the AppShell/LayoutContent links that keep the main area a non-scroll container. Verified against untouched main with Playwright + pixelmatch at six viewports x ten scroll offsets: 0 px at the default threshold, identical layer geometry, `overscroll-behavior-y: auto` everywhere, no fixed element reaching the viewport bottom (was 2-3), 11/11 hover/focus/swipe checks passing on both trees, resize-while-scrolled and theme-swap mid-scroll 0 px. Safari and a real rubber-band are not verified here. Step 2 of #5392. Fixes #5470. Supersedes the zero-height-pin approach in #5431 and folds in the backdropGlow comment correction from #5467. --- .../__tests__/home-hero-overscroll.test.ts | 274 ++++++++++++++++++ .../_landing/hero/HeroFloatingCards.tsx | 21 +- .../(site)/_landing/hero/HeroThemeReel.tsx | 256 +++++++++------- apps/docsite/src/app/(site)/page.tsx | 54 +++- apps/docsite/src/app/globals.css | 12 - packages/core/src/Layout/LayoutContent.tsx | 4 + 6 files changed, 489 insertions(+), 132 deletions(-) create mode 100644 apps/docsite/src/__tests__/home-hero-overscroll.test.ts diff --git a/apps/docsite/src/__tests__/home-hero-overscroll.test.ts b/apps/docsite/src/__tests__/home-hero-overscroll.test.ts new file mode 100644 index 0000000000000..9ab324c56b0e3 --- /dev/null +++ b/apps/docsite/src/__tests__/home-hero-overscroll.test.ts @@ -0,0 +1,274 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file home-hero-overscroll.test.ts + * @input globals.css, the home hero sources and core's AppShell/LayoutContent, + * read as text + * @output Invariants that keep native overscroll alive on every docsite route + * @position Regression guard for #5392 / #5470 (the hero's pin containment) + * + * `overscroll-behavior-y: none` on the root element is how you turn off + * pull-to-refresh (mobile) and the trackpad rubber-band (macOS). The docsite + * carried it app-wide (#3032), then desktop-only (#5415), to hide the home + * hero's `position: fixed` layers from the strip an overscroll opens past the + * end of the page. The layers are bounded now, so the rule is gone (#5470). + * + * The docsite suite is node-only with StyleX untransformed, so these are + * source invariants (the idiom of component-preview-theme.test.ts): read the + * files as text and assert on the declarations that would let the bleed — + * and therefore the rule — come back. + * + * Run: pnpm -F @astryxdesign/docsite test src/__tests__/home-hero-overscroll.test.ts + */ + +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 APP = path.join(HERE, '..', 'app'); +const SITE = path.join(APP, '(site)'); +const HERO = path.join(SITE, '_landing', 'hero'); +const CORE = path.join(HERE, '..', '..', '..', '..', 'packages', 'core', 'src'); + +function read(file: string): string { + const source = fs.readFileSync(file, 'utf8'); + // Anti-vacuity: a moved or emptied file must fail loudly, not pass silently. + expect(source.length, `${file} is empty`).toBeGreaterThan(200); + return source; +} + +/** Drop comments so prose about `fixed` or `none` can't match. */ +function stripComments(source: string): string { + return source + .replace(/\/\*[^]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + .replace(/([,;{}])\s*\/\/.*$/gm, '$1'); +} + +/** The brace-balanced `{...}` starting at `open` (which must be a `{`). */ +function balanced(source: string, open: number): string { + 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 at ${open}`); +} + +/** The `{...}` body of one top-level `stylex.create` entry, e.g. `backdropGlow`. */ +function styleBlock(source: string, name: string): string { + const match = new RegExp(`^ ${name}: \\{`, 'm').exec(source); + expect(match, `no \`${name}\` style entry in the source`).not.toBeNull(); + return balanced(source, match!.index + match![0].length - 1); +} + +/** + * The raw text of one property's value inside a style block: the scalar up to + * its trailing comma, or the whole brace-balanced conditional object. Asserting + * on this text is independent of key syntax (`default`, a quoted media query, + * or a computed `[BREAKPOINT]` key) and of line layout. + */ +function valueText(block: string, property: string): string { + const match = new RegExp(`\\n\\s*${property}: `).exec(block); + expect(match, `no \`${property}\` declaration in the block`).not.toBeNull(); + const start = match!.index + match![0].length; + if (block[start] === '{') { + return balanced(block, start); + } + const end = block.indexOf('\n', start); + return block.slice(start, end).replace(/,\s*$/, '').trim(); +} + +/** + * Every value a property takes — the scalar, or each arm of its conditional + * object — unquoted. Parsing is strict: an arm this helper cannot read fails + * the test rather than vanishing from the result. + */ +function allValues(block: string, property: string): string[] { + const text = valueText(block, property); + const unquote = (v: string) => v.trim().replace(/^(['"])(.*)\1$/, '$2'); + if (!text.startsWith('{')) { + return [unquote(text)]; + } + const inner = text.slice(1, -1); + // One arm per line as prettier writes them, or top-level commas when the + // whole object fits on one line. + const arms = ( + inner.includes('\n') + ? inner.split('\n') + : inner.split(/,\s*(?=default\b|['"[])/) + ) + .map(l => l.trim().replace(/,$/, '')) + .filter(l => l.length > 0); + expect(arms.length, `no arms in \`${property}\``).toBeGreaterThan(0); + return arms.map(arm => { + const parsed = /^(?:default|'[^']*'|"[^"]*"|\[[^\]]+\])\s*:\s*(.+)$/.exec( + arm, + ); + expect(parsed, `unreadable arm in \`${property}\`: ${arm}`).not.toBeNull(); + return unquote(parsed![1]); + }); +} + +describe('docsite globals.css never suppresses overscroll (#5392, #5470)', () => { + it('has no overscroll-behavior declaration other than auto, at any width', () => { + const css = stripComments(read(path.join(APP, 'globals.css'))); + const offenders: string[] = []; + for (const match of css.matchAll( + /overscroll-behavior(?:-x|-y|-block|-inline)?\s*:\s*([^;}]+)/gi, + )) { + const value = match[1].trim(); + if (value !== 'auto') { + // Name the enclosing at-rule so a re-scoped rule is still reported. + const before = css.slice(0, match.index); + const atRule = /@media[^{]*\{(?:[^{}]|\{[^{}]*\})*$/.exec(before)?.[0]; + offenders.push( + `${atRule ? atRule.split('{')[0].trim() + ' > ' : ''}overscroll-behavior: ${value}`, + ); + } + } + expect(offenders).toEqual([]); + }); +}); + +/** + * A `fixed` layer is glued to the viewport and is not part of the document, + * so when the document rubber-bands past its own edge the layer sits in the + * exposed gap. A `sticky` layer lifts with the document and cannot paint past + * its containing block — that is structural, which is what let the root rule + * be deleted rather than narrowed. None of the hero's pinned layers may go + * back to `fixed` in any media arm. + * + * `navBackdrop` is deliberately not listed: it is a header-height strip at + * the very top of the viewport and never reaches the bottom gap. + */ +describe('home hero pinned layers are never position: fixed (#5470)', () => { + const layers: ReadonlyArray<[string, string, string]> = [ + ['hero text block', path.join(SITE, 'page.tsx'), 'heroContent'], + ['aurora glow', path.join(HERO, 'HeroThemeReel.tsx'), 'backdropGlow'], + ['floating cards stage', path.join(HERO, 'HeroFloatingCards.tsx'), 'stage'], + ]; + + for (const [label, file, style] of layers) { + it(`${label} (${style})`, () => { + const block = styleBlock(stripComments(read(file)), style); + // Raw text first: catches `fixed` under any key syntax or line layout. + expect(valueText(block, 'position')).not.toMatch(/['"]fixed['"]/); + // Then the parsed arms, which must all be readable. + const positions = allValues(block, 'position'); + expect(positions.length).toBeGreaterThan(0); + expect(positions).not.toContain('fixed'); + }); + } +}); + +/** + * Sticky is bounded by its containing block, so each pinned layer rides in a + * full-height rail that spans the hero band AND the showcase (a rail inside + * the 760px band alone releases the hero after 48px of scroll). The rails are + * absolute against heroScope, and every hero layer ties at z-index 0/auto, so + * tree order is paint order: the rails must come before the showcase overlay + * or the showcase stops covering them. + */ +describe('home hero rails are bounded by heroScope and precede the showcase (#5470)', () => { + const page = () => stripComments(read(path.join(SITE, 'page.tsx'))); + const reel = () => stripComments(read(path.join(HERO, 'HeroThemeReel.tsx'))); + + it('heroScope is the positioned ancestor and the rails fill it', () => { + expect(allValues(styleBlock(page(), 'heroScope'), 'position')).toEqual([ + 'relative', + ]); + const rail = styleBlock(reel(), 'rail'); + expect(allValues(rail, 'position')).toEqual(['absolute']); + expect(allValues(rail, 'inset')).toEqual(['0']); + const contentRail = styleBlock(page(), 'heroContentRail'); + expect(allValues(contentRail, 'position')).toContain('absolute'); + expect(allValues(contentRail, 'inset')).toContain('0'); + }); + + it('sizes the pinned boxes against the rail, never the viewport', () => { + // 100vw includes a classic scrollbar, so it can exceed the rail's width; + // an over-wide block zeroes its auto margins and shoves the box left. + expect( + valueText(styleBlock(reel(), 'backdropGlow'), 'width'), + ).not.toContain('100vw'); + const cards = stripComments(read(path.join(HERO, 'HeroFloatingCards.tsx'))); + expect(valueText(styleBlock(cards, 'stage'), 'width')).not.toContain( + '100vw', + ); + }); + + it('renders the backdrop, cards and hero-text rails inside heroScope, before showcaseOverlay', () => { + const source = page(); + const jsx = source.slice( + source.indexOf('export default function HomePage'), + ); + expect(jsx.length).toBeGreaterThan(200); + const at = (needle: string) => { + const index = jsx.indexOf(needle); + expect(index, `\`${needle}\` not rendered by HomePage`).toBeGreaterThan( + -1, + ); + return index; + }; + const scope = at('styles.heroScope'); + const showcase = at('styles.showcaseOverlay'); + const backdrop = at(' { + it('LayoutContent.styles.content uses overflow: clip and no per-axis longhand', () => { + const source = stripComments( + read(path.join(CORE, 'Layout', 'LayoutContent.tsx')), + ); + const content = styleBlock(source, 'content'); + expect(allValues(content, 'overflow')).toEqual(['clip']); + expect(content).not.toMatch(/\n\s*overflow(?:X|Y|Block|Inline)\s*:/); + // The scroll container is opt-in, via a separate style. + expect(allValues(styleBlock(source, 'scrollable'), 'overflow')).toEqual([ + 'auto', + ]); + }); + + it('AppShell only makes the main area scrollable in fill mode', () => { + const source = stripComments( + read(path.join(CORE, 'AppShell', 'AppShell.tsx')), + ); + expect(source).toMatch(/const isFill = height === 'fill'/); + const main = source.indexOf('id={MAIN_CONTENT_ID}'); + expect(main, 'main LayoutContent not found').toBeGreaterThan(-1); + const openingTag = source.slice(source.lastIndexOf(''))).toMatch( + /isScrollable=\{isFill\}/, + ); + }); + + it('the landing layout renders AppShell in auto height', () => { + const source = stripComments(read(path.join(SITE, 'layout.tsx'))); + expect(source).toMatch(/]*\sheight="auto"/); + }); +}); diff --git a/apps/docsite/src/app/(site)/_landing/hero/HeroFloatingCards.tsx b/apps/docsite/src/app/(site)/_landing/hero/HeroFloatingCards.tsx index 0265cdff5bfb2..29510153897eb 100644 --- a/apps/docsite/src/app/(site)/_landing/hero/HeroFloatingCards.tsx +++ b/apps/docsite/src/app/(site)/_landing/hero/HeroFloatingCards.tsx @@ -34,15 +34,22 @@ const REWARD_MEMBER_NAME = 'Ami Pena'; const REWARD_MEMBER_AVATAR = '/images/avatars/DATA-Ami-Pena.png'; const styles = stylex.create({ - // Desktop overlap stage: fixed, viewport-centered 1200px box (shared with the - // aurora blobs) so cards track the blobs on resize. Capped to 100vw to avoid - // horizontal scroll. Hidden <1024px, where the collage takes over. + // Desktop overlap stage: a centered 1200px box (shared with the aurora blobs) + // so cards track the blobs on resize. Capped to the rail's width, never + // 100vw: with a classic scrollbar 100vw is wider than the rail, which zeroes + // the auto margins and shoves the box left. Hidden <1024px, where the + // collage takes over. + // + // Sticky inside HeroThemeReel's cards rail, not fixed: it pins under the + // header for the whole pin-and-cover but lifts with the document, so it can + // never paint into an overscroll gap (#5470). Centered by auto margins in + // the full-width rail — on a sticky box `left` is an inset, so the old + // `left: 50%; translateX(-50%)` trick would shift it instead. stage: { - position: 'fixed', + position: 'sticky', top: 'var(--appshell-header-height, 0px)', - left: '50%', - transform: 'translateX(-50%)', - width: 'min(1200px, 100vw)', + marginInline: 'auto', + width: 'min(1200px, 100%)', height: 1050, pointerEvents: 'none', display: { diff --git a/apps/docsite/src/app/(site)/_landing/hero/HeroThemeReel.tsx b/apps/docsite/src/app/(site)/_landing/hero/HeroThemeReel.tsx index 896939d6299ed..4f16d6fa7806b 100644 --- a/apps/docsite/src/app/(site)/_landing/hero/HeroThemeReel.tsx +++ b/apps/docsite/src/app/(site)/_landing/hero/HeroThemeReel.tsx @@ -5,16 +5,24 @@ /** * @file HeroThemeReel.tsx * @input none (reads the generated theme registry via heroThemeContent) - * @output Provider + placed pieces (wordmark, cards, dots) consumed by page.tsx + * @output Provider, the swipe/hover surface, the pinned backdrop + cards rails, + * and placed pieces (wordmark, collage, dots) consumed by page.tsx * @position Home hero — orchestrates the per-theme reel behind the headline. * * The cycling state (active index + auto-advance clock) lives in HeroReelProvider * so the wordmark, cards, and dots — placed in different parts of the DOM by - * page.tsx — re-skin together. Auto-advance pauses on hover/focus and when the - * tab is hidden, and respects prefers-reduced-motion. + * page.tsx — re-skin together. Auto-advance pauses on hover/focus (inside + * HeroReelSwipeArea) and when the tab is hidden, and respects + * prefers-reduced-motion. * * Manual control: touch swipe (mobile) and the Pagination dots, which own * keyboard navigation (arrow keys, Home/End) via the useListFocus primitive. + * + * Desktop pin-and-cover: the backdrop (fills + aurora glow) and the overlap + * cards each ride their own full-height rail (`styles.rail`, absolute against + * page.tsx's heroScope) as a `position: sticky` layer. Sticky, not fixed, is + * what bounds them to the document so the site never has to suppress native + * overscroll to hide them (#3032 → #5392 → #5470). */ import { @@ -29,6 +37,7 @@ import { type TouchEvent as ReactTouchEvent, } from 'react'; import * as stylex from '@stylexjs/stylex'; +import type {StyleXStyles} from '@stylexjs/stylex'; import {Theme} from '@astryxdesign/core/theme'; import {Text} from '@astryxdesign/core/Text'; import {Pagination} from '@astryxdesign/core/Pagination'; @@ -123,15 +132,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: { - position: 'sticky', - top: 'var(--appshell-header-height, 0px)', - height: 0, - width: '100%', + // Full-height rail for one pinned layer. Absolute against heroScope + // (page.tsx, position: relative), so it spans the hero band AND the + // showcase: a `position: sticky` child pins for the whole pin-and-cover, yet + // lifts with the document and can never paint past its own edge — which is + // what lets the site keep native overscroll instead of suppressing it + // (#5470). A rail inside the 760px band alone releases its layer after ~48px + // of scroll. Decorative: never intercepts pointer events. + rail: { + position: 'absolute', + inset: 0, pointerEvents: 'none', - zIndex: 0, }, // Per-slide body fill behind the hero (resolves to the active theme's body // color). Covers the band + an extra strip so the color sits behind the @@ -170,32 +181,41 @@ const styles = stylex.create({ }, // 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. + // below that (see `position`). Capped to the rail's width (see the stage). // 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). + // Desktop: sticky inside its rail, part of the pin-and-cover effect + // alongside heroContent and the cards stage; sticky rather than fixed so + // it lifts with the document and cannot bleed into an overscroll gap + // (#5470). Narrow: absolute within the rail, 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 is what let that rule scope to + // desktop widths (#5392); the sticky rails are what let it go entirely + // (#5470). position: { default: 'absolute', - '@media (min-width: 1024px)': 'fixed', + '@media (min-width: 1024px)': 'sticky', }, - // 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. + // The rail starts where heroScope does, below the header, so the absolute + // case needs no offset; the sticky case pins under the header. top: { default: 0, '@media (min-width: 1024px)': 'var(--appshell-header-height, 0px)', }, - left: '50%', - transform: 'translateX(-50%)', - width: 'min(1200px, 100vw)', + // Centered by auto margins in the full-width rail. The old + // `left: 50%; translateX(-50%)` can't survive the move: on a sticky box + // `left` is an inset, not an offset. The absolute case needs both inline + // insets set for the auto margins to resolve. + insetInline: { + default: 0, + '@media (min-width: 1024px)': 'auto', + }, + marginInline: 'auto', + width: 'min(1200px, 100%)', height: 1050, pointerEvents: 'none', opacity: 0.7, @@ -243,8 +263,9 @@ const dynamic = stylex.create({ /** * Owns the cycling state + auto-advance clock and exposes them via context. - * Renders no DOM of its own beyond the provider + a hover/focus wrapper so the - * hero can pause cycling while the user interacts with it. + * Renders no DOM of its own — HeroReelSwipeArea is the hover/focus/touch + * surface — so the pinned rails and the hero text can all be direct children + * of page.tsx's heroScope while sharing one reel. */ export function HeroReelProvider({children}: {children: ReactNode}) { const slides = HERO_THEME_SLIDES; @@ -267,45 +288,6 @@ export function HeroReelProvider({children}: {children: ReactNode}) { [slides.length], ); - // Touch swipe (mobile): swipe left → next theme, right → previous. - const touchStart = useRef<{x: number; y: number} | null>(null); - const SWIPE_THRESHOLD_PX = 45; - const onTouchStart = useCallback((e: ReactTouchEvent) => { - const t = e.touches[0]; - if (!t) { - return; - } - touchStart.current = {x: t.clientX, y: t.clientY}; - // Touch devices have no hover, so pause auto-advance while the finger is down. - setPaused(true); - }, []); - const onTouchEnd = useCallback( - (e: ReactTouchEvent) => { - setPaused(false); - const start = touchStart.current; - touchStart.current = null; - if (!start || slides.length <= 1) { - return; - } - const t = e.changedTouches[0]; - if (!t) { - return; - } - const dx = t.clientX - start.x; - const dy = t.clientY - start.y; - // Only a mostly-horizontal gesture counts, so vertical scroll isn't a swipe. - if (Math.abs(dx) < SWIPE_THRESHOLD_PX || Math.abs(dx) <= Math.abs(dy)) { - return; - } - setIndex(i => { - const count = slides.length; - const next = dx < 0 ? i + 1 : i - 1; - return ((next % count) + count) % count; - }); - }, - [slides.length], - ); - useEffect(() => { if ( !AUTOPLAY_ENABLED || @@ -374,19 +356,76 @@ export function HeroReelProvider({children}: {children: ReactNode}) { [slides, index, goTo, reduceMotion, userMode], ); + return {children}; +} + +// A horizontal touch travel shorter than this is a tap or a scroll, not a swipe. +const SWIPE_THRESHOLD_PX = 45; + +/** + * The reel's interaction surface: hover or focus inside pauses auto-advance, + * and a horizontal touch swipe steps the reel (left → next, right → previous). + * Wraps the hero text block — CTAs, dots and the narrow-screen collage — and + * page.tsx also makes it the hero text's full-height rail, so on desktop the + * band and its gutters pause the reel exactly as the old provider wrapper did. + */ +export function HeroReelSwipeArea({ + children, + xstyle, +}: { + children: ReactNode; + xstyle?: StyleXStyles; +}) { + const reel = useHeroReel(); + const setPaused = reel?.setPaused; + + const touchStart = useRef<{x: number; y: number} | null>(null); + const onTouchStart = useCallback( + (e: ReactTouchEvent) => { + const t = e.touches[0]; + if (!t) { + return; + } + touchStart.current = {x: t.clientX, y: t.clientY}; + // Touch devices have no hover, so pause auto-advance while the finger is down. + setPaused?.(true); + }, + [setPaused], + ); + const onTouchEnd = useCallback( + (e: ReactTouchEvent) => { + setPaused?.(false); + const start = touchStart.current; + touchStart.current = null; + if (!start || !reel || reel.slides.length <= 1) { + return; + } + const t = e.changedTouches[0]; + if (!t) { + return; + } + const dx = t.clientX - start.x; + const dy = t.clientY - start.y; + // Only a mostly-horizontal gesture counts, so vertical scroll isn't a swipe. + if (Math.abs(dx) < SWIPE_THRESHOLD_PX || Math.abs(dx) <= Math.abs(dy)) { + return; + } + reel.goTo(reel.index + (dx < 0 ? 1 : -1)); + }, + [reel, setPaused], + ); + return ( - -
setPaused(true)} - onMouseLeave={() => setPaused(false)} - onFocusCapture={() => setPaused(true)} - onBlurCapture={() => setPaused(false)} - onTouchStart={onTouchStart} - onTouchEnd={onTouchEnd}> - {children} -
-
+
setPaused?.(true)} + onMouseLeave={() => setPaused?.(false)} + onFocusCapture={() => setPaused?.(true)} + onBlurCapture={() => setPaused?.(false)} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd}> + {children} +
); } @@ -430,7 +469,40 @@ export function HeroReelWordmark() { ); } -/** Full-bleed floating cards layer for the hero gutters. */ +/** + * The backdrop rail: the per-slide body fill, the nav retint strip and the + * aurora glow. First of the pinned rails in page.tsx, so it paints under the + * cards and the hero text. + */ +export function HeroReelBackdrop() { + const reel = useHeroReel(); + if (!reel || reel.slides.length === 0) { + return null; + } + + const active = reel.slides[reel.index]; + return ( +
+ +