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:
@@ -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}
|
||||
|
||||
+17
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user