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.
102 lines
2.9 KiB
TypeScript
102 lines
2.9 KiB
TypeScript
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
|
|
// appearance never passes through props.
|
|
const TAPER_HEIGHT = 15.477;
|
|
const TAPER_TOP_OFFSET = 4;
|
|
const RIGHT_CAP_WIDTH = 15;
|
|
|
|
const ShapeRow = styled.span`
|
|
display: flex;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
position: absolute;
|
|
|
|
svg {
|
|
display: block;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
path[data-fill] {
|
|
fill: var(--button-fill, transparent);
|
|
transition: fill ${DURATION.md} ${EASING.gentle};
|
|
}
|
|
|
|
path[data-stroke] {
|
|
fill: none;
|
|
stroke: var(--button-stroke, transparent);
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
stroke-width: 1;
|
|
transition: stroke ${DURATION.md} ${EASING.gentle};
|
|
}
|
|
`;
|
|
|
|
const Middle = styled.span`
|
|
background-color: var(--button-fill, transparent);
|
|
border-block: 1px solid var(--button-stroke, transparent);
|
|
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 =>
|
|
`M4 0 A4 4 0 0 0 0 4 V${height - 4} A4 4 0 0 0 4 ${height} Z`;
|
|
|
|
const leftStrokePath = (height: number): string =>
|
|
`M4 0.5 A3.5 3.5 0 0 0 0.5 4 V${height - 4} A3.5 3.5 0 0 0 4 ${height - 0.5}`;
|
|
|
|
const rightFillPath = (height: number): string => {
|
|
const straight = Math.max(height - TAPER_TOP_OFFSET - TAPER_HEIGHT, 0);
|
|
return `M0 0 h11 a4 4 0 0 1 4 4 v${straight} a6 6 0 0 1 -1.544 4.019 l-8.548 9.477 A6 6 0 0 1 0.453 ${height} H0 Z`;
|
|
};
|
|
|
|
const rightStrokePath = (height: number): string => {
|
|
const straight = Math.max(height - TAPER_TOP_OFFSET - TAPER_HEIGHT, 0);
|
|
return `M0 0.5 h11 a3.5 3.5 0 0 1 3.5 3.5 v${straight} a5.5 5.5 0 0 1 -1.416 3.684 l-8.547 9.477 a5.5 5.5 0 0 1 -4.084 1.816 H0`;
|
|
};
|
|
|
|
export type ButtonShapeProps = {
|
|
heightPx: number;
|
|
outlined?: boolean;
|
|
};
|
|
|
|
export function ButtonShape({ heightPx, outlined = false }: ButtonShapeProps) {
|
|
return (
|
|
<ShapeRow aria-hidden>
|
|
<svg
|
|
height={heightPx}
|
|
viewBox={`0 0 4 ${heightPx}`}
|
|
width="4"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
{outlined ? (
|
|
<path d={leftStrokePath(heightPx)} data-stroke />
|
|
) : (
|
|
<path d={leftFillPath(heightPx)} data-fill />
|
|
)}
|
|
</svg>
|
|
<Middle />
|
|
<svg
|
|
height={heightPx}
|
|
viewBox={`0 0 ${RIGHT_CAP_WIDTH} ${heightPx}`}
|
|
width={RIGHT_CAP_WIDTH}
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
{outlined ? (
|
|
<path d={rightStrokePath(heightPx)} data-stroke />
|
|
) : (
|
|
<path d={rightFillPath(heightPx)} data-fill />
|
|
)}
|
|
</svg>
|
|
</ShapeRow>
|
|
);
|
|
}
|