fix(website): polish product hero AI transition (panel reveal timing + title wrapping) (#22223)

## Summary

Two desktop/tablet polish refinements to the product page hero's hero→AI
transition (`product-hero`).

### 1. Delay the Ask-AI panel reveal to after the wipe (`b638d600`)

The Ask-AI side panel used to reveal *during* the dark "wipe up" (keyed
to the morph `0.45 → 0.70`), so it competed with the rising black edge
and piled onto the heaviest motion phase — rough on weaker laptops.

It's now keyed to raw scroll progress over `[0.60, 0.70]` — the
previously-idle hold after the wipe settles at `0.55` — so it slides in
on its own beat once the eye has followed the black up. `morphProgress`
is pinned at `1` across the whole post-wipe stretch, so leaving the
morph clock was the only way to time the panel *after* the wipe. The
conversation playback moves with it, so the chat doesn't stream while
the panel is still `width: 0`.

### 2. Keep the AI heading to three lines below desktop (`8ae69a4b`)

The AI heading is the page's longest line. Below `md`, only the mobile
copy renders, where the measure was frozen at `360px` while the heading
font scales fluidly toward `md` — so on tablet widths the title crammed
onto four lines, recovering only at `921px` when the `672px` measure
kicks in.

Adds an `sm`-breakpoint measure step (`560px`) scoped to the AI heading;
the intro heading and the desktop measuring path keep their existing
`360/672` steps.

## Test plan

- `product-hero-scroll-model` jest suite updated and green (11/11),
including a new post-morph panel-timing test.
- Lint (`check-conventions` + `oxlint` + `oxfmt`) and typecheck green.
- Visually confirmed: the panel reveals after the wipe settles, and the
AI title holds three lines across tablet widths.
This commit is contained in:
Abdullah.
2026-06-26 19:43:45 +05:00
committed by GitHub
parent adf56cac56
commit 1b4bddba6f
3 changed files with 37 additions and 6 deletions
@@ -214,6 +214,12 @@ const HeadingSlot = styled.div`
position: relative;
width: 100%;
${mediaUp('sm')} {
&[data-measure='ai'] {
max-width: 560px;
}
}
${mediaUp('md')} {
max-width: 672px;
}
@@ -648,7 +654,7 @@ export function HeroVisualScroll({
<MobileSection $secondary>
<IntroContainer>
<HeadingGroup>
<HeadingSlot>
<HeadingSlot data-measure="ai">
<ContentLayer data-active>
<Heading as="h2" size="lg" weight="light">
{aiHeading}
@@ -30,11 +30,13 @@ describe('computeHeroScrollModel', () => {
it('should complete the morph at 55% of the scrollable run', () => {
const model = atScroll(0.55);
expect(model.morphProgress).toBe(1);
expect(model.aiPanelProgress).toBe(1);
expect(model.aiPlaybackEnabled).toBe(true);
expect(model.selectorRevealReady).toBe(true);
expect(model.stackSpreadProgress).toBe(1);
expect(model.stackSpreadEasedProgress).toBe(1);
// The Ask-AI panel is deliberately still closed here; it reveals only
// in the post-morph hold (see the panel-timing test below).
expect(model.aiPanelProgress).toBe(0);
expect(model.aiPlaybackEnabled).toBe(false);
});
it('should ease the morph through smoothstep', () => {
@@ -51,7 +53,19 @@ describe('computeHeroScrollModel', () => {
expect(half.stackAppearProgress).toBeCloseTo((0.5 - 0.4) / 0.16, 10);
expect(half.stackAlignProgress).toBe(0);
expect(half.stackSpreadProgress).toBe(0);
expect(half.aiPanelProgress).toBeCloseTo((0.5 - 0.45) / 0.25, 10);
});
it('should hold the Ask-AI panel back until the post-morph hold', () => {
// The wipe finishes at scroll 0.55; the panel reveals over [0.60, 0.70]
// of raw scroll progress, then holds open, and playback begins only
// once it has fully opened.
expect(atScroll(0.55).aiPanelProgress).toBe(0);
expect(atScroll(0.55).aiPlaybackEnabled).toBe(false);
expect(atScroll(0.65).aiPanelProgress).toBeCloseTo(0.5, 10);
expect(atScroll(0.65).aiPlaybackEnabled).toBe(false);
expect(atScroll(0.7).aiPanelProgress).toBe(1);
expect(atScroll(0.7).aiPlaybackEnabled).toBe(true);
expect(atScroll(1).aiPanelProgress).toBe(1);
});
it('should gate the AI layer at the wipe midpoint', () => {
@@ -40,6 +40,14 @@ const NAV_HEIGHT_PX = 64;
const MORPH_START = 0;
const MORPH_END = 0.55;
// The Ask-AI panel holds back until the wipe has fully risen, then slides
// in during the post-morph hold as its own beat — so the eye, having
// followed the black edge up, lands on the panel instead of competing
// with it. Keyed to raw scroll progress: morph is pinned at 1 through
// this whole stretch and so can't time anything past MORPH_END.
const AI_PANEL_REVEAL_START = 0.6;
const AI_PANEL_REVEAL_END = 0.7;
function smoothstep(value: number): number {
return value * value * (3 - 2 * value);
}
@@ -102,8 +110,11 @@ export function computeHeroScrollModel({
const selectorRevealProgress = clampProgress((morphProgress - 0.94) / 0.06);
return {
aiPanelProgress: clampProgress((morphProgress - 0.45) / 0.25),
aiPlaybackEnabled: morphProgress >= 0.7,
aiPanelProgress: clampProgress(
(progress - AI_PANEL_REVEAL_START) /
(AI_PANEL_REVEAL_END - AI_PANEL_REVEAL_START),
),
aiPlaybackEnabled: progress >= AI_PANEL_REVEAL_END,
aiPointerEventsEnabled: morphProgress > 0.5,
heroAtStart: morphProgress <= 0,
introCursorsActive: morphProgress < 0.5,