From 03f3789d13c9b692aa29ea2d7d53fe93271c1894 Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:58:50 +0500 Subject: [PATCH] feat(website): menu adapts to the section beneath it (#22241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../src/app/[locale]/(site)/layout.tsx | 15 +---- .../src/app/[locale]/(site)/product/page.tsx | 23 ++++--- .../twenty-website/src/icons/TwentyLogo.tsx | 17 ++++-- .../platform/menu-style/MenuStyleProvider.tsx | 6 +- .../find-active-surface-scheme.test.ts | 51 ++++++++++++++++ .../menu-style/find-active-surface-scheme.ts | 11 ++++ .../platform/menu-style/menu-style-context.ts | 6 +- .../menu-style/use-active-surface-scheme.ts | 46 ++++++++++++++ .../src/platform/menu-style/use-menu-style.ts | 18 ++++-- .../src/sections/footer/Footer.tsx | 2 +- .../twenty-website/src/sections/menu/Menu.tsx | 39 ++++++------ .../menu/{ => components}/MenuDrawer.tsx | 8 +-- .../menu/{ => components}/MenuDropdown.tsx | 2 +- .../menu/{ => components}/MenuNav.tsx | 4 +- .../menu/{ => components}/MenuSocial.tsx | 4 +- .../menu/{menu.data.ts => data/menu.ts} | 37 ++---------- .../CloseDrawerOnDesktopEffect.tsx | 2 - .../ScrollStateEffect.tsx | 0 .../menu/types/menu-nav-child-preview.ts | 10 ++++ .../src/sections/menu/types/menu-nav-child.ts | 14 +++++ .../src/sections/menu/types/menu-nav-item.ts | 9 +++ .../sections/menu/types/menu-social-link.ts | 11 ++++ .../product-hero/HeroVisualScroll.tsx | 60 ++++++++++++------- .../product-hero-scroll-model.test.ts | 11 ++-- .../product-hero/product-hero-scroll-model.ts | 28 ++++----- packages/twenty-website/src/tokens/index.ts | 1 + .../twenty-website/src/tokens/menu-height.ts | 1 + .../twenty-website/src/ui/ButtonShape.tsx | 7 +++ .../twenty-website/src/ui/SectionShell.tsx | 1 + 29 files changed, 290 insertions(+), 154 deletions(-) create mode 100644 packages/twenty-website/src/platform/menu-style/find-active-surface-scheme.test.ts create mode 100644 packages/twenty-website/src/platform/menu-style/find-active-surface-scheme.ts create mode 100644 packages/twenty-website/src/platform/menu-style/use-active-surface-scheme.ts rename packages/twenty-website/src/sections/menu/{ => components}/MenuDrawer.tsx (95%) rename packages/twenty-website/src/sections/menu/{ => components}/MenuDropdown.tsx (99%) rename packages/twenty-website/src/sections/menu/{ => components}/MenuNav.tsx (95%) rename packages/twenty-website/src/sections/menu/{ => components}/MenuSocial.tsx (94%) rename packages/twenty-website/src/sections/menu/{menu.data.ts => data/menu.ts} (81%) rename packages/twenty-website/src/sections/menu/{ => effect-components}/CloseDrawerOnDesktopEffect.tsx (81%) rename packages/twenty-website/src/sections/menu/{ => effect-components}/ScrollStateEffect.tsx (100%) create mode 100644 packages/twenty-website/src/sections/menu/types/menu-nav-child-preview.ts create mode 100644 packages/twenty-website/src/sections/menu/types/menu-nav-child.ts create mode 100644 packages/twenty-website/src/sections/menu/types/menu-nav-item.ts create mode 100644 packages/twenty-website/src/sections/menu/types/menu-social-link.ts create mode 100644 packages/twenty-website/src/tokens/menu-height.ts diff --git a/packages/twenty-website/src/app/[locale]/(site)/layout.tsx b/packages/twenty-website/src/app/[locale]/(site)/layout.tsx index fb66a742d8..1304936f3c 100644 --- a/packages/twenty-website/src/app/[locale]/(site)/layout.tsx +++ b/packages/twenty-website/src/app/[locale]/(site)/layout.tsx @@ -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 ( - <> + {children}