Files
twenty/packages/twenty-website/src/ui/SectionShell.tsx
T
Abdullah. 03f3789d13 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.
2026-06-27 12:58:50 +05:00

157 lines
3.5 KiB
TypeScript

import { css } from '@linaria/core';
import { type ReactNode } from 'react';
import {
buildSchemeContext,
MAX_CONTENT_WIDTH_PX,
mediaUp,
RHYTHM,
type Scheme,
semanticColor,
spacing,
} from '@/tokens';
import { Container } from './Container';
const sectionShellClassName = css`
background-color: ${semanticColor.surface};
min-width: 0;
overflow: clip;
position: relative;
width: 100%;
&[data-rhythm='section'] {
padding-block: ${spacing(RHYTHM.section.top.base)}
${spacing(RHYTHM.section.bottom.base)};
${mediaUp('md')} {
padding-block: ${spacing(RHYTHM.section.top.md)}
${spacing(RHYTHM.section.bottom.md)};
}
}
&[data-rhythm='flush'] {
overflow: visible;
padding-block: 0;
}
&[data-rhythm='spacious'] {
padding-block: ${spacing(RHYTHM.spacious.top.base)}
${spacing(RHYTHM.spacious.bottom.base)};
${mediaUp('md')} {
padding-block: ${spacing(RHYTHM.spacious.top.md)}
${spacing(RHYTHM.spacious.bottom.md)};
}
}
&[data-rhythm='hero'] {
padding-block: ${spacing(RHYTHM.hero.top.base)}
${spacing(RHYTHM.hero.bottom.base)};
${mediaUp('md')} {
padding-block: ${spacing(RHYTHM.hero.top.md)}
${spacing(RHYTHM.hero.bottom.md)};
}
}
&[data-scheme='light'] {
${buildSchemeContext('light')}
}
&[data-scheme='muted'] {
${buildSchemeContext('muted')}
}
&[data-scheme='dark'] {
${buildSchemeContext('dark')}
}
&[data-scheme='light']:not([data-rhythm='flush'])
+ &[data-scheme='light']:not([data-keep-top-rhythm]),
&[data-scheme='muted']:not([data-rhythm='flush'])
+ &[data-scheme='muted']:not([data-keep-top-rhythm]),
&[data-scheme='dark']:not([data-rhythm='flush'])
+ &[data-scheme='dark']:not([data-keep-top-rhythm]) {
padding-top: ${spacing(1.5)};
}
&:has(+ &[data-connect-up]) {
overflow: visible;
padding-bottom: 0;
z-index: 1;
}
`;
const backgroundLayerClassName = css`
inset: 0;
margin-inline: auto;
max-width: ${MAX_CONTENT_WIDTH_PX}px;
overflow: clip;
pointer-events: none;
position: absolute;
z-index: 0;
&[data-full-bleed] {
max-width: none;
}
`;
const contentLayerClassName = css`
position: relative;
z-index: 1;
`;
export type SectionShellProps = {
ariaLabel?: string;
background?: ReactNode;
children: ReactNode;
connectsUp?: boolean;
flushInline?: boolean;
fullBleedBackground?: boolean;
keepsTopRhythm?: boolean;
rhythm?: 'section' | 'hero' | 'spacious' | 'flush';
scheme?: Scheme;
};
export function SectionShell({
ariaLabel,
background,
children,
connectsUp = false,
flushInline = false,
fullBleedBackground = false,
keepsTopRhythm = false,
rhythm = 'section',
scheme = 'light',
}: SectionShellProps) {
return (
<section
aria-label={ariaLabel}
className={sectionShellClassName}
data-connect-up={connectsUp ? '' : undefined}
data-keep-top-rhythm={keepsTopRhythm ? '' : undefined}
data-menu-surface=""
data-rhythm={rhythm}
data-scheme={scheme}
>
{background !== undefined && (
<div
aria-hidden
className={backgroundLayerClassName}
data-background-layer=""
data-full-bleed={fullBleedBackground ? '' : undefined}
>
{background}
</div>
)}
<Container
className={contentLayerClassName}
data-flush-inline={flushInline ? '' : undefined}
>
{children}
</Container>
</section>
);
}