feat(website): menu adapts to the section beneath it (#22241)
## What The sticky menu now adopts the color scheme of whatever section sits beneath it as you scroll, across every page. Background, logo, and buttons ease between schemes for a smooth handoff. ## How - **Declarative contract** — sections opt in with a `data-menu-surface` attribute (`SectionShell`, the footer, and the product hero's mobile sections); `useActiveSurfaceScheme` tracks which surface sits under the menu's bottom edge (`MENU_HEIGHT_PX`) and reports its `data-scheme`. - **One provider** — `MenuStyleProvider` lives in the `(site)` layout so every page adapts; the Menu resolves its scheme as `override ?? activeScheme ?? prop`. SSR seeds the prop to each page's first section, so there's no mount flash. - **Hero handoff** — the product hero keeps the menu via a per-frame override only while its track still covers the nav band (`controlsMenu`); it releases to the observer *before* the track clears the bar, so the menu stays opaque on exit and never flashes the halftone backdrop rising behind it. The menu's `backdrop-filter` was removed (a no-op over the opaque menu, and a GPU compositing artifact). ## Also in this PR - **Menu folder reorg** to the `product-feature` convention: `components/`, `effect-components/`, `data/`, and `types/` (one domain type per file). - **`MENU_HEIGHT_PX` token** replacing the literal `64` that was duplicated across four files (menu row, the hero's scroll model and component, the observer). - **`findActiveSurfaceScheme`** extracted as a pure, unit-tested function (inclusive top / exclusive bottom, first-match, no-match, null-scheme). - **Mobile AI-section fix** — the mobile hero sections now declare the surface contract, so the menu adapts dark over the AI block on mobile (it previously stayed light). Their color is driven from `data-scheme` (single source) rather than a parallel prop. ## Testing - Jest — 16 tests (scroll model + surface-selection util). - Headless Playwright (mobile 390px) — menu `light` over the intro → `dark` over the AI section; section colors unchanged (`#fff` / `rgb(20,20,20)`). - Desktop unaffected — the mobile sections are `display:none` (zero rect), so the observer skips them and the hero override path is untouched. - Gates green — typecheck, oxlint, oxfmt, check-conventions.
This commit is contained in:
@@ -4,18 +4,9 @@ import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/platform/i18n/get-route-i18n';
|
||||
import { MenuStyleProvider } from '@/platform/menu-style';
|
||||
import { Footer } from '@/sections/footer';
|
||||
|
||||
// The site-chrome layout. Marketing pages live under (site) and get the shared
|
||||
// footer here; focused pages (the application form) live in (focused) with no
|
||||
// chrome. The footer is config-less shared chrome, so it belongs in a layout —
|
||||
// the route structure decides who shows it, not a runtime visibility gate. The
|
||||
// Menu stays per-page because its scheme varies per page (and product's is
|
||||
// scroll-synced), so it can't be hoisted here without a flash.
|
||||
//
|
||||
// The footer reads i18n (getServerI18n), so this layout establishes the
|
||||
// request-scoped i18n context the same way every [locale] route segment does:
|
||||
// the segment that renders i18n content owns its getRouteI18n call.
|
||||
const SiteLayout = async ({
|
||||
children,
|
||||
params,
|
||||
@@ -26,10 +17,10 @@ const SiteLayout = async ({
|
||||
await getRouteI18n(params);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuStyleProvider>
|
||||
{children}
|
||||
<Footer />
|
||||
</>
|
||||
</MenuStyleProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type LocaleRouteParams,
|
||||
} from '@/platform/i18n/get-route-i18n';
|
||||
import { resolveLocaleParam } from '@/platform/i18n/resolve-locale-param';
|
||||
import { MenuStyleProvider } from '@/platform/menu-style';
|
||||
import {
|
||||
buildBreadcrumbListJsonLd,
|
||||
buildRouteMetadata,
|
||||
@@ -43,18 +42,16 @@ export default async function ProductPage({
|
||||
locale,
|
||||
)}
|
||||
/>
|
||||
<MenuStyleProvider>
|
||||
<Menu communityStats={communityStats} />
|
||||
<main>
|
||||
<ProductHero />
|
||||
<TrustedBy />
|
||||
<ProductFeature />
|
||||
<ProductThreeCards />
|
||||
<ProductStepper />
|
||||
<ProductSignoff />
|
||||
<Faq />
|
||||
</main>
|
||||
</MenuStyleProvider>
|
||||
<Menu communityStats={communityStats} />
|
||||
<main>
|
||||
<ProductHero />
|
||||
<TrustedBy />
|
||||
<ProductFeature />
|
||||
<ProductThreeCards />
|
||||
<ProductStepper />
|
||||
<ProductSignoff />
|
||||
<Faq />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { semanticColor } from '@/tokens';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { DURATION, EASING, semanticColor } from '@/tokens';
|
||||
|
||||
const TWENTY_MARK_PATH =
|
||||
'M6.822 14.174c0-2.435 1.996-4.411 4.456-4.411h8.574c.125 0 .241.075.293.19a.31.31 0 0 1-.056.344l-1.88 2.023c-.326.35-.787.552-1.27.552H11.3c-.738 0-1.338.594-1.338 1.325v3.336a.78.78 0 0 1-.783.777H7.61a.78.78 0 0 1-.783-.777v-3.36zM33.5 25.553c0 2.434-1.996 4.411-4.456 4.411h-3.642c-2.46 0-4.454-1.977-4.454-4.411v-6.315c0-.43.16-.842.456-1.16l2.124-2.285a.33.33 0 0 1 .355-.081.32.32 0 0 1 .205.295v9.527c0 .73.598 1.322 1.337 1.322h3.6a1.33 1.33 0 0 0 1.337-1.322V14.197c0-.73-.599-1.325-1.337-1.325H24.84c-.481 0-.938.201-1.265.547L11.088 26.856h7.503a.78.78 0 0 1 .784.778v1.552a.78.78 0 0 1-.784.778H8.481a1.655 1.655 0 0 1-1.662-1.644v-.824c0-.412.156-.809.44-1.114l13.999-15.06a4.9 4.9 0 0 1 3.594-1.56h4.189c2.46 0 4.454 1.977 4.454 4.412v11.379z';
|
||||
|
||||
const LogoSvg = styled.svg`
|
||||
rect,
|
||||
path {
|
||||
transition: fill ${DURATION.md} ${EASING.gentle};
|
||||
}
|
||||
`;
|
||||
|
||||
export type TwentyLogoProps = {
|
||||
sizePx?: number;
|
||||
};
|
||||
|
||||
// Brand mark on its scheme's ink, so it inverts on dark surfaces with no
|
||||
// color props.
|
||||
export function TwentyLogo({ sizePx = 40 }: TwentyLogoProps) {
|
||||
return (
|
||||
<svg
|
||||
<LogoSvg
|
||||
fill="none"
|
||||
height={sizePx}
|
||||
viewBox="0 0 40 40"
|
||||
@@ -20,6 +27,6 @@ export function TwentyLogo({ sizePx = 40 }: TwentyLogoProps) {
|
||||
>
|
||||
<rect fill={semanticColor.ink} height={40} rx={4} width={40} />
|
||||
<path d={TWENTY_MARK_PATH} fill={semanticColor.surface} />
|
||||
</svg>
|
||||
</LogoSvg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
import { MENU_STYLE_BACKGROUND_VAR } from './menu-style-background';
|
||||
import { MenuStyleContext, type MenuStyleOverride } from './menu-style-context';
|
||||
import { useActiveSurfaceScheme } from './use-active-surface-scheme';
|
||||
|
||||
export function MenuStyleProvider({ children }: { children: ReactNode }) {
|
||||
const [override, setOverride] = useState<MenuStyleOverride>({});
|
||||
const activeScheme = useActiveSurfaceScheme();
|
||||
|
||||
const setBackground = useCallback((value: string | null) => {
|
||||
const root = document.documentElement;
|
||||
@@ -19,8 +21,8 @@ export function MenuStyleProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ override, setBackground, setOverride }),
|
||||
[override, setBackground],
|
||||
() => ({ activeScheme, override, setBackground, setOverride }),
|
||||
[activeScheme, override, setBackground],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { findActiveSurfaceScheme } from './find-active-surface-scheme';
|
||||
|
||||
describe('findActiveSurfaceScheme', () => {
|
||||
it('should return the scheme of the surface spanning the line', () => {
|
||||
expect(
|
||||
findActiveSurfaceScheme(
|
||||
[
|
||||
{ top: -200, bottom: 40, scheme: 'light' },
|
||||
{ top: 40, bottom: 600, scheme: 'dark' },
|
||||
],
|
||||
64,
|
||||
),
|
||||
).toBe('dark');
|
||||
});
|
||||
|
||||
it('should treat the top edge as inclusive and the bottom edge as exclusive', () => {
|
||||
expect(
|
||||
findActiveSurfaceScheme(
|
||||
[
|
||||
{ top: 0, bottom: 64, scheme: 'light' },
|
||||
{ top: 64, bottom: 400, scheme: 'dark' },
|
||||
],
|
||||
64,
|
||||
),
|
||||
).toBe('dark');
|
||||
});
|
||||
|
||||
it('should return the first matching surface in document order', () => {
|
||||
expect(
|
||||
findActiveSurfaceScheme(
|
||||
[
|
||||
{ top: 0, bottom: 200, scheme: 'light' },
|
||||
{ top: 50, bottom: 300, scheme: 'dark' },
|
||||
],
|
||||
64,
|
||||
),
|
||||
).toBe('light');
|
||||
});
|
||||
|
||||
it('should return null when no surface spans the line', () => {
|
||||
expect(
|
||||
findActiveSurfaceScheme([{ top: 100, bottom: 500, scheme: 'muted' }], 64),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the spanning surface declares no scheme', () => {
|
||||
expect(
|
||||
findActiveSurfaceScheme([{ top: 0, bottom: 400, scheme: null }], 64),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type Scheme } from '@/tokens';
|
||||
|
||||
export function findActiveSurfaceScheme(
|
||||
surfaces: readonly { top: number; bottom: number; scheme: Scheme | null }[],
|
||||
line: number,
|
||||
): Scheme | null {
|
||||
const active = surfaces.find(
|
||||
(surface) => surface.top <= line && surface.bottom > line,
|
||||
);
|
||||
return active?.scheme ?? null;
|
||||
}
|
||||
@@ -4,17 +4,13 @@ import { createContext } from 'react';
|
||||
|
||||
import { type Scheme } from '@/tokens';
|
||||
|
||||
// Discrete restyles a page may apply to the sticky Menu. Continuous
|
||||
// values (the scroll-interpolated background) travel as a CSS custom
|
||||
// property instead — see MENU_STYLE_BACKGROUND_VAR — so no React work
|
||||
// happens per scroll frame.
|
||||
export type MenuStyleOverride = {
|
||||
scheme?: Scheme;
|
||||
suppressBackdropBlur?: boolean;
|
||||
suppressElevation?: boolean;
|
||||
};
|
||||
|
||||
type MenuStyleContextValue = {
|
||||
activeScheme: Scheme | null;
|
||||
override: MenuStyleOverride;
|
||||
setBackground: (value: string | null) => void;
|
||||
setOverride: (override: MenuStyleOverride) => void;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useUnlocalizedPathname } from '@/platform/i18n/use-unlocalized-pathname';
|
||||
import { useScheduledOnScroll } from '@/platform/motion';
|
||||
import { MENU_HEIGHT_PX, type Scheme } from '@/tokens';
|
||||
|
||||
import { findActiveSurfaceScheme } from './find-active-surface-scheme';
|
||||
|
||||
function readScheme(element: Element): Scheme | null {
|
||||
const value = element.getAttribute('data-scheme');
|
||||
return value === 'light' || value === 'muted' || value === 'dark'
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export function useActiveSurfaceScheme(): Scheme | null {
|
||||
const [activeScheme, setActiveScheme] = useState<Scheme | null>(null);
|
||||
const surfacesRef = useRef<Element[]>([]);
|
||||
const pathname = useUnlocalizedPathname();
|
||||
|
||||
const sync = useCallback(() => {
|
||||
const surfaces = surfacesRef.current.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
scheme: readScheme(element),
|
||||
};
|
||||
});
|
||||
const next = findActiveSurfaceScheme(surfaces, MENU_HEIGHT_PX);
|
||||
setActiveScheme((previous) => (previous === next ? previous : next));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
surfacesRef.current = Array.from(
|
||||
document.querySelectorAll('[data-menu-surface]'),
|
||||
);
|
||||
sync();
|
||||
}, [pathname, sync]);
|
||||
|
||||
useScheduledOnScroll(sync);
|
||||
|
||||
return activeScheme;
|
||||
}
|
||||
@@ -2,12 +2,22 @@
|
||||
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { type Scheme } from '@/tokens';
|
||||
|
||||
import { MenuStyleContext, type MenuStyleOverride } from './menu-style-context';
|
||||
|
||||
const NO_OVERRIDE: MenuStyleOverride = {};
|
||||
|
||||
// The Menu reads its page-level restyle; without a provider it renders
|
||||
// its own defaults untouched.
|
||||
export function useMenuStyle(): MenuStyleOverride {
|
||||
return useContext(MenuStyleContext)?.override ?? NO_OVERRIDE;
|
||||
export type MenuStyleState = {
|
||||
activeScheme: Scheme | null;
|
||||
override: MenuStyleOverride;
|
||||
};
|
||||
|
||||
export function useMenuStyle(): MenuStyleState {
|
||||
const context = useContext(MenuStyleContext);
|
||||
|
||||
return {
|
||||
activeScheme: context?.activeScheme ?? null,
|
||||
override: context?.override ?? NO_OVERRIDE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ const Card = styled.div`
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<FooterRoot>
|
||||
<FooterRoot data-menu-surface="" data-scheme="dark">
|
||||
<StageContainer>
|
||||
<FooterBackdrop />
|
||||
<Card>
|
||||
|
||||
@@ -14,10 +14,12 @@ import { LocalizedLink } from '@/platform/i18n/LocalizedLink';
|
||||
import { MENU_STYLE_BACKGROUND_VAR, useMenuStyle } from '@/platform/menu-style';
|
||||
import {
|
||||
SHADOW,
|
||||
DURATION,
|
||||
EASING,
|
||||
buildSchemeDeclarations,
|
||||
color,
|
||||
mediaUp,
|
||||
MENU_HEIGHT_PX,
|
||||
type Scheme,
|
||||
semanticColor,
|
||||
spacing,
|
||||
@@ -25,22 +27,22 @@ import {
|
||||
} from '@/tokens';
|
||||
import { Button, Container, IconButton } from '@/ui';
|
||||
|
||||
import { CloseDrawerOnDesktopEffect } from './CloseDrawerOnDesktopEffect';
|
||||
import { MENU } from './menu.data';
|
||||
import { MenuDrawer } from './MenuDrawer';
|
||||
import { MenuNav } from './MenuNav';
|
||||
import { MenuSocial } from './MenuSocial';
|
||||
import { ScrollStateEffect } from './ScrollStateEffect';
|
||||
import { MenuDrawer } from './components/MenuDrawer';
|
||||
import { MenuNav } from './components/MenuNav';
|
||||
import { MenuSocial } from './components/MenuSocial';
|
||||
import { MENU } from './data/menu';
|
||||
import { CloseDrawerOnDesktopEffect } from './effect-components/CloseDrawerOnDesktopEffect';
|
||||
import { ScrollStateEffect } from './effect-components/ScrollStateEffect';
|
||||
|
||||
// Safari < 18 still needs the -webkit- prefix for backdrop-filter.
|
||||
const headerClassName = css`
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: var(${MENU_STYLE_BACKGROUND_VAR}, ${semanticColor.surface});
|
||||
color: ${semanticColor.ink};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
transition: box-shadow 0.2s ${EASING.gentle};
|
||||
transition:
|
||||
background-color ${DURATION.md} ${EASING.gentle},
|
||||
box-shadow 0.2s ${EASING.gentle},
|
||||
color ${DURATION.md} ${EASING.gentle};
|
||||
width: 100%;
|
||||
z-index: ${Z_INDEX.stickyHeader};
|
||||
|
||||
@@ -60,11 +62,8 @@ const headerClassName = css`
|
||||
box-shadow: ${SHADOW.header};
|
||||
}
|
||||
|
||||
/* A scroll-driven page (the product hero) interpolates its own
|
||||
background; blur over the moving wipe reads as smearing. */
|
||||
&[data-blur-suppressed] {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
&[data-pinned] {
|
||||
transition: box-shadow 0.2s ${EASING.gentle};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -73,7 +72,7 @@ const MenuRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${spacing(5)};
|
||||
justify-content: space-between;
|
||||
min-height: 64px;
|
||||
min-height: ${MENU_HEIGHT_PX}px;
|
||||
`;
|
||||
|
||||
const LogoLink = styled(LocalizedLink)`
|
||||
@@ -113,10 +112,10 @@ export type MenuProps = {
|
||||
|
||||
export function Menu({ communityStats, scheme = 'light' }: MenuProps) {
|
||||
const { i18n } = useLingui();
|
||||
const menuStyle = useMenuStyle();
|
||||
const { activeScheme, override } = useMenuStyle();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [isElevated, setIsElevated] = useState(false);
|
||||
const resolvedScheme = menuStyle.scheme ?? scheme;
|
||||
const resolvedScheme = override.scheme ?? activeScheme ?? scheme;
|
||||
|
||||
const handleScrollStateChange = useCallback(
|
||||
(hasScrolled: boolean, isScrolling: boolean) => {
|
||||
@@ -133,10 +132,10 @@ export function Menu({ communityStats, scheme = 'light' }: MenuProps) {
|
||||
<ScrollStateEffect onScrollStateChange={handleScrollStateChange} />
|
||||
<header
|
||||
className={headerClassName}
|
||||
data-blur-suppressed={menuStyle.suppressBackdropBlur ? '' : undefined}
|
||||
data-elevated={
|
||||
isElevated && !menuStyle.suppressElevation ? '' : undefined
|
||||
isElevated && !override.suppressElevation ? '' : undefined
|
||||
}
|
||||
data-pinned={override.scheme !== undefined ? '' : undefined}
|
||||
data-scheme={resolvedScheme}
|
||||
>
|
||||
<Container>
|
||||
|
||||
+3
-5
@@ -24,10 +24,10 @@ import {
|
||||
} from '@/tokens';
|
||||
import { Button, ExternalArrow, ExternalLink, VerticalDivider } from '@/ui';
|
||||
|
||||
import { MENU, type MenuNavItem, type MenuSocialLink } from './menu.data';
|
||||
import { MENU } from '../data/menu';
|
||||
import { type MenuNavItem } from '../types/menu-nav-item';
|
||||
import { type MenuSocialLink } from '../types/menu-social-link';
|
||||
|
||||
// Portaled out of the header, the panel re-establishes the menu's scheme
|
||||
// itself (a portal escapes the [data-scheme] context).
|
||||
const DrawerPanel = styled(Drawer.Popup)`
|
||||
&[data-scheme='light'] {
|
||||
${buildSchemeDeclarations('light')}
|
||||
@@ -65,8 +65,6 @@ const DrawerPanel = styled(Drawer.Popup)`
|
||||
}
|
||||
`;
|
||||
|
||||
// The nav block centers as a group in the remaining height, with dotted
|
||||
// hairlines between items only — both ported from the original.
|
||||
const DrawerNav = styled.nav`
|
||||
align-content: center;
|
||||
display: grid;
|
||||
+1
-1
@@ -21,7 +21,7 @@ import {
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
|
||||
import { type MenuNavChild } from './menu.data';
|
||||
import { type MenuNavChild } from '../types/menu-nav-child';
|
||||
|
||||
const DropdownLayout = styled.div`
|
||||
display: grid;
|
||||
+1
-3
@@ -24,8 +24,8 @@ import {
|
||||
Z_INDEX,
|
||||
} from '@/tokens';
|
||||
|
||||
import { type MenuNavItem } from '../types/menu-nav-item';
|
||||
import { MenuDropdown } from './MenuDropdown';
|
||||
import { type MenuNavItem } from './menu.data';
|
||||
|
||||
const NavList = styled(NavigationMenu.List)`
|
||||
display: none;
|
||||
@@ -40,8 +40,6 @@ const NavList = styled(NavigationMenu.List)`
|
||||
}
|
||||
`;
|
||||
|
||||
// Ported item treatment: zero box padding with an expanded invisible
|
||||
// hit-area, hover/active in highlight blue, active = centered short bar.
|
||||
const navItemStyles = `
|
||||
background: none;
|
||||
border: none;
|
||||
+1
-3
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
import { ExternalArrow, ExternalLink, VerticalDivider } from '@/ui';
|
||||
|
||||
import { type MenuSocialLink } from './menu.data';
|
||||
import { type MenuSocialLink } from '../types/menu-social-link';
|
||||
|
||||
const SocialRow = styled.nav`
|
||||
display: none;
|
||||
@@ -30,8 +30,6 @@ const SocialRow = styled.nav`
|
||||
}
|
||||
`;
|
||||
|
||||
// The Discord chip only earns its space on wide viewports, matching the
|
||||
// original behavior.
|
||||
const SocialItem = styled.span`
|
||||
align-items: center;
|
||||
column-gap: ${spacing(5)};
|
||||
+4
-33
@@ -1,4 +1,3 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
IconBook,
|
||||
@@ -8,41 +7,13 @@ import {
|
||||
IconTag,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { DiscordMark, GitHubMark, type IconComponent } from '@/icons';
|
||||
|
||||
import { DiscordMark, GitHubMark } from '@/icons';
|
||||
import { LATEST_RELEASE } from '@/platform/releases';
|
||||
import { SITE_URLS } from '@/platform/site-urls';
|
||||
|
||||
export type MenuNavChildPreview = {
|
||||
image: string;
|
||||
imageAlt: MessageDescriptor;
|
||||
imagePosition?: string;
|
||||
imageScale?: number;
|
||||
title: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
};
|
||||
|
||||
export type MenuNavChild = {
|
||||
label: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
icon: IconComponent;
|
||||
preview: MenuNavChildPreview;
|
||||
};
|
||||
|
||||
export type MenuNavItem = {
|
||||
label: MessageDescriptor;
|
||||
href?: string;
|
||||
children?: readonly MenuNavChild[];
|
||||
};
|
||||
|
||||
export type MenuSocialLink = {
|
||||
ariaLabel: MessageDescriptor;
|
||||
href: string;
|
||||
icon: IconComponent;
|
||||
showInDesktop: boolean;
|
||||
statKey?: 'githubStars' | 'discordMembers';
|
||||
};
|
||||
import { type MenuNavItem } from '../types/menu-nav-item';
|
||||
import { type MenuSocialLink } from '../types/menu-social-link';
|
||||
|
||||
export const MENU: {
|
||||
appUrl: string;
|
||||
-2
@@ -8,8 +8,6 @@ export type CloseDrawerOnDesktopEffectProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// The drawer is a sub-md affordance: when the viewport grows past the
|
||||
// breakpoint, the desktop nav appears and the drawer must not linger.
|
||||
export function CloseDrawerOnDesktopEffect({
|
||||
onClose,
|
||||
}: CloseDrawerOnDesktopEffectProps) {
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type MenuNavChildPreview = {
|
||||
image: string;
|
||||
imageAlt: MessageDescriptor;
|
||||
imagePosition?: string;
|
||||
imageScale?: number;
|
||||
title: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type IconComponent } from '@/icons';
|
||||
|
||||
import { type MenuNavChildPreview } from './menu-nav-child-preview';
|
||||
|
||||
export type MenuNavChild = {
|
||||
label: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
icon: IconComponent;
|
||||
preview: MenuNavChildPreview;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type MenuNavChild } from './menu-nav-child';
|
||||
|
||||
export type MenuNavItem = {
|
||||
label: MessageDescriptor;
|
||||
href?: string;
|
||||
children?: readonly MenuNavChild[];
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type IconComponent } from '@/icons';
|
||||
|
||||
export type MenuSocialLink = {
|
||||
ariaLabel: MessageDescriptor;
|
||||
href: string;
|
||||
icon: IconComponent;
|
||||
showInDesktop: boolean;
|
||||
statKey?: 'githubStars' | 'discordMembers';
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fontSize,
|
||||
MAX_CONTENT_WIDTH_PX,
|
||||
mediaUp,
|
||||
MENU_HEIGHT_PX,
|
||||
PRODUCT_HERO_SCENE,
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
@@ -54,10 +55,6 @@ type StackTargetMetric = {
|
||||
width: number;
|
||||
};
|
||||
|
||||
// The sticky menu's height: the track tucks under it so the wipe line
|
||||
// meets the nav band exactly.
|
||||
const NAV_HEIGHT_PX = 64;
|
||||
|
||||
const DESKTOP_QUERY = `(min-width: ${BREAKPOINT_PX.md}px)`;
|
||||
|
||||
// The deck's authored resting fan: vertical offsets and scales per card.
|
||||
@@ -70,6 +67,7 @@ type HeroDiscreteState = Pick<
|
||||
HeroScrollModel,
|
||||
| 'aiPlaybackEnabled'
|
||||
| 'aiPointerEventsEnabled'
|
||||
| 'controlsMenu'
|
||||
| 'heroAtStart'
|
||||
| 'introCursorsActive'
|
||||
| 'isCrossing'
|
||||
@@ -81,6 +79,7 @@ type HeroDiscreteState = Pick<
|
||||
const INITIAL_DISCRETE: HeroDiscreteState = {
|
||||
aiPlaybackEnabled: false,
|
||||
aiPointerEventsEnabled: false,
|
||||
controlsMenu: true,
|
||||
heroAtStart: true,
|
||||
introCursorsActive: true,
|
||||
isCrossing: false,
|
||||
@@ -93,6 +92,7 @@ function pickDiscrete(model: HeroScrollModel): HeroDiscreteState {
|
||||
return {
|
||||
aiPlaybackEnabled: model.aiPlaybackEnabled,
|
||||
aiPointerEventsEnabled: model.aiPointerEventsEnabled,
|
||||
controlsMenu: model.controlsMenu,
|
||||
heroAtStart: model.heroAtStart,
|
||||
introCursorsActive: model.introCursorsActive,
|
||||
isCrossing: model.isCrossing,
|
||||
@@ -106,6 +106,7 @@ function sameDiscrete(a: HeroDiscreteState, b: HeroDiscreteState): boolean {
|
||||
return (
|
||||
a.aiPlaybackEnabled === b.aiPlaybackEnabled &&
|
||||
a.aiPointerEventsEnabled === b.aiPointerEventsEnabled &&
|
||||
a.controlsMenu === b.controlsMenu &&
|
||||
a.heroAtStart === b.heroAtStart &&
|
||||
a.introCursorsActive === b.introCursorsActive &&
|
||||
a.isCrossing === b.isCrossing &&
|
||||
@@ -131,7 +132,7 @@ const ScrollTrack = styled.div`
|
||||
${mediaUp('md')} {
|
||||
display: block;
|
||||
height: 200vh;
|
||||
margin-top: -${NAV_HEIGHT_PX}px;
|
||||
margin-top: -${MENU_HEIGHT_PX}px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -372,9 +373,6 @@ const VisualWrapper = styled.div`
|
||||
|
||||
const MobileSection = styled.div<{ $secondary?: boolean }>`
|
||||
align-items: center;
|
||||
background-color: ${({ $secondary }) =>
|
||||
$secondary ? PRODUCT_HERO_SCENE.darkSurface : color('white')};
|
||||
color: ${({ $secondary }) => ($secondary ? color('white') : color('black'))};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -385,6 +383,16 @@ const MobileSection = styled.div<{ $secondary?: boolean }>`
|
||||
& > * + * {
|
||||
margin-top: ${spacing(6)};
|
||||
}
|
||||
|
||||
&[data-scheme='light'] {
|
||||
background-color: ${color('white')};
|
||||
color: ${color('black')};
|
||||
}
|
||||
|
||||
&[data-scheme='dark'] {
|
||||
background-color: ${PRODUCT_HERO_SCENE.darkSurface};
|
||||
color: ${color('white')};
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileVisualWrapper = styled.div`
|
||||
@@ -464,9 +472,10 @@ export function HeroVisualScroll({
|
||||
String(model.aiPanelProgress),
|
||||
);
|
||||
|
||||
if (menuWriter && model.menuBackground !== lastMenuBackgroundRef.current) {
|
||||
lastMenuBackgroundRef.current = model.menuBackground;
|
||||
menuWriter.setBackground(model.menuBackground);
|
||||
const nextBackground = model.controlsMenu ? model.menuBackground : null;
|
||||
if (menuWriter && nextBackground !== lastMenuBackgroundRef.current) {
|
||||
lastMenuBackgroundRef.current = nextBackground;
|
||||
menuWriter.setBackground(nextBackground);
|
||||
}
|
||||
|
||||
const nextDiscrete = pickDiscrete(model);
|
||||
@@ -479,7 +488,7 @@ export function HeroVisualScroll({
|
||||
|
||||
// The page's menu restyle: scheme/elevation flip at thresholds; below
|
||||
// the desktop split the menu keeps its defaults (the mobile layout
|
||||
// scrolls normally). Blur stays off on this page — it smears the wipe.
|
||||
// scrolls normally).
|
||||
useEffect(() => {
|
||||
if (!menuWriter) {
|
||||
return undefined;
|
||||
@@ -488,18 +497,27 @@ export function HeroVisualScroll({
|
||||
if (!isDesktop) {
|
||||
menuWriter.setBackground(null);
|
||||
lastMenuBackgroundRef.current = null;
|
||||
menuWriter.setOverride({ suppressBackdropBlur: true });
|
||||
menuWriter.setOverride({});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
menuWriter.setOverride({
|
||||
scheme: discrete.menuDark ? 'dark' : 'light',
|
||||
suppressBackdropBlur: true,
|
||||
suppressElevation: !discrete.menuElevated,
|
||||
});
|
||||
menuWriter.setOverride(
|
||||
discrete.controlsMenu
|
||||
? {
|
||||
scheme: discrete.menuDark ? 'dark' : 'light',
|
||||
suppressElevation: !discrete.menuElevated,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}, [menuWriter, isDesktop, discrete.menuDark, discrete.menuElevated]);
|
||||
}, [
|
||||
menuWriter,
|
||||
isDesktop,
|
||||
discrete.controlsMenu,
|
||||
discrete.menuDark,
|
||||
discrete.menuElevated,
|
||||
]);
|
||||
|
||||
// Leaving the page hands the menu back untouched.
|
||||
useEffect(() => {
|
||||
@@ -615,7 +633,7 @@ export function HeroVisualScroll({
|
||||
|
||||
const mobileLayout = (
|
||||
<MobileRoot>
|
||||
<MobileSection>
|
||||
<MobileSection data-menu-surface="" data-scheme="light">
|
||||
<IntroContainer>
|
||||
<HeadingGroup>
|
||||
<HeadingSlot>
|
||||
@@ -651,7 +669,7 @@ export function HeroVisualScroll({
|
||||
</MobileCursorLayer>
|
||||
</MobileSection>
|
||||
|
||||
<MobileSection $secondary>
|
||||
<MobileSection $secondary data-menu-surface="" data-scheme="dark">
|
||||
<IntroContainer>
|
||||
<HeadingGroup>
|
||||
<HeadingSlot data-measure="ai">
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('computeHeroScrollModel', () => {
|
||||
expect(model.menuBackground).toBe('rgb(255, 255, 255)');
|
||||
expect(model.menuElevated).toBe(true);
|
||||
expect(model.menuDark).toBe(false);
|
||||
expect(model.controlsMenu).toBe(true);
|
||||
expect(model.stackAppearProgress).toBe(0);
|
||||
});
|
||||
|
||||
@@ -95,17 +96,14 @@ describe('computeHeroScrollModel', () => {
|
||||
expect(settled.menuElevated).toBe(false);
|
||||
});
|
||||
|
||||
it('should hand off transparently while the track bottom crosses the nav band', () => {
|
||||
// Track bottom 32px into the viewport: the dark slab's edge is inside
|
||||
// the nav band, so the menu shows the real surfaces beneath it.
|
||||
it('should release to the ambient observer once the track bottom enters the nav band', () => {
|
||||
const exiting = computeHeroScrollModel({
|
||||
trackTop: 32 - TRACK,
|
||||
trackHeight: TRACK,
|
||||
viewportHeight: VIEWPORT,
|
||||
});
|
||||
expect(exiting.isCrossing).toBe(true);
|
||||
expect(exiting.menuBackground).toBe('transparent');
|
||||
expect(exiting.menuElevated).toBe(false);
|
||||
expect(exiting.controlsMenu).toBe(false);
|
||||
expect(exiting.isCrossing).toBe(false);
|
||||
});
|
||||
|
||||
it('should release the menu as the track scrolls out', () => {
|
||||
@@ -117,6 +115,7 @@ describe('computeHeroScrollModel', () => {
|
||||
});
|
||||
expect(out.navProgress).toBe(0);
|
||||
expect(out.menuBackground).toBe('rgb(255, 255, 255)');
|
||||
expect(out.controlsMenu).toBe(false);
|
||||
});
|
||||
|
||||
it('should treat a degenerate track as unscrolled', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// properties and keeps React state only for the discrete flips.
|
||||
|
||||
import { clampProgress } from '@/platform/motion';
|
||||
import { MENU_WIPE } from '@/tokens';
|
||||
import { MENU_HEIGHT_PX, MENU_WIPE } from '@/tokens';
|
||||
|
||||
type HeroScrollInput = {
|
||||
// getBoundingClientRect().top of the 200vh track.
|
||||
@@ -18,6 +18,7 @@ export type HeroScrollModel = {
|
||||
aiPanelProgress: number;
|
||||
aiPlaybackEnabled: boolean;
|
||||
aiPointerEventsEnabled: boolean;
|
||||
controlsMenu: boolean;
|
||||
heroAtStart: boolean;
|
||||
introCursorsActive: boolean;
|
||||
isCrossing: boolean;
|
||||
@@ -34,8 +35,6 @@ export type HeroScrollModel = {
|
||||
stackSpreadProgress: number;
|
||||
};
|
||||
|
||||
const NAV_HEIGHT_PX = 64;
|
||||
|
||||
// The morph plays out over the first 55% of the track's scrollable run.
|
||||
const MORPH_START = 0;
|
||||
const MORPH_END = 0.55;
|
||||
@@ -79,28 +78,20 @@ export function computeHeroScrollModel({
|
||||
let navProgress = 0;
|
||||
if (wipeLineY <= 0) {
|
||||
navProgress = 1;
|
||||
} else if (wipeLineY < NAV_HEIGHT_PX) {
|
||||
navProgress = smoothstep(1 - wipeLineY / NAV_HEIGHT_PX);
|
||||
} else if (wipeLineY < MENU_HEIGHT_PX) {
|
||||
navProgress = smoothstep(1 - wipeLineY / MENU_HEIGHT_PX);
|
||||
}
|
||||
|
||||
if (trackBottom <= 0) {
|
||||
navProgress = 0;
|
||||
} else if (trackBottom < NAV_HEIGHT_PX) {
|
||||
navProgress *= smoothstep(trackBottom / NAV_HEIGHT_PX);
|
||||
} else if (trackBottom < MENU_HEIGHT_PX) {
|
||||
navProgress *= smoothstep(trackBottom / MENU_HEIGHT_PX);
|
||||
}
|
||||
|
||||
// The menu goes transparent while a dark edge slides through the nav
|
||||
// band, so the edge reads as passing through the bar. The old site only
|
||||
// did this for the entry wipe; the exit (the track's bottom leaving the
|
||||
// viewport) faded through greys that matched neither surface —
|
||||
// user-ratified fix: both crossings hand off transparently.
|
||||
const isEntryCrossing =
|
||||
const isCrossing =
|
||||
morphProgress < 1 &&
|
||||
wipeLineY <= NAV_HEIGHT_PX &&
|
||||
trackBottom > NAV_HEIGHT_PX;
|
||||
const isExitCrossing =
|
||||
navProgress > 0 && trackBottom <= NAV_HEIGHT_PX && trackBottom > 0;
|
||||
const isCrossing = isEntryCrossing || isExitCrossing;
|
||||
wipeLineY <= MENU_HEIGHT_PX &&
|
||||
trackBottom > MENU_HEIGHT_PX;
|
||||
|
||||
const menuBackground = isCrossing
|
||||
? MENU_WIPE.transparent
|
||||
@@ -116,6 +107,7 @@ export function computeHeroScrollModel({
|
||||
),
|
||||
aiPlaybackEnabled: progress >= AI_PANEL_REVEAL_END,
|
||||
aiPointerEventsEnabled: morphProgress > 0.5,
|
||||
controlsMenu: trackBottom > MENU_HEIGHT_PX,
|
||||
heroAtStart: morphProgress <= 0,
|
||||
introCursorsActive: morphProgress < 0.5,
|
||||
isCrossing,
|
||||
|
||||
@@ -16,6 +16,7 @@ export { GUTTER } from './gutter';
|
||||
export { ICON_INKS } from './icon-inks';
|
||||
export { MAX_CONTENT_WIDTH_PX } from './max-content-width';
|
||||
export { mediaUp } from './media-up';
|
||||
export { MENU_HEIGHT_PX } from './menu-height';
|
||||
export { MENU_WIPE } from './menu-wipe';
|
||||
export { MODAL_SURFACE } from './modal-surface';
|
||||
export { buildSchemeContext } from './build-scheme-context';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MENU_HEIGHT_PX = 64;
|
||||
@@ -1,5 +1,7 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { DURATION, EASING } from '@/tokens';
|
||||
|
||||
// Purely geometric 9-slice for the brand's beveled button: rounded left cap,
|
||||
// stretching middle, curved-bevel right cap. All color comes from the
|
||||
// --button-fill / --button-stroke variables set by the consuming button, so
|
||||
@@ -21,6 +23,7 @@ const ShapeRow = styled.span`
|
||||
|
||||
path[data-fill] {
|
||||
fill: var(--button-fill, transparent);
|
||||
transition: fill ${DURATION.md} ${EASING.gentle};
|
||||
}
|
||||
|
||||
path[data-stroke] {
|
||||
@@ -29,6 +32,7 @@ const ShapeRow = styled.span`
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1;
|
||||
transition: stroke ${DURATION.md} ${EASING.gentle};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -38,6 +42,9 @@ const Middle = styled.span`
|
||||
flex-grow: 1;
|
||||
margin-inline: -0.5px;
|
||||
min-width: 0;
|
||||
transition:
|
||||
background-color ${DURATION.md} ${EASING.gentle},
|
||||
border-color ${DURATION.md} ${EASING.gentle};
|
||||
`;
|
||||
|
||||
const leftFillPath = (height: number): string =>
|
||||
|
||||
@@ -131,6 +131,7 @@ export function SectionShell({
|
||||
className={sectionShellClassName}
|
||||
data-connect-up={connectsUp ? '' : undefined}
|
||||
data-keep-top-rhythm={keepsTopRhythm ? '' : undefined}
|
||||
data-menu-surface=""
|
||||
data-rhythm={rhythm}
|
||||
data-scheme={scheme}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user