03f3789d13
## 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.
157 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|