Files
twenty/packages/twenty-website/src/ui/ButtonShape.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

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>
);
}