Split website stepper into home-stepper and product-stepper sections (#22149)

Reorganizes the flat `sections/stepper/` (30 files — two distinct
steppers plus shared code) into two product-feature-style sections, and
moves the shared code to the shared layers.

- **`sections/home-stepper/`** — the home-page stepper. Renamed
`Stepper` → `HomeStepper` (and the home components → `HomeStepperLottie`
/ `HomeStepperSteps` / `HomeStepperVisualFrame`) for symmetry with
`ProductStepper`. Shell at the root + `components/`/`data/`/`utils/` +
barrel.
- **`sections/product-stepper/`** — the product-page stepper, same
structure.

The 3 files both steppers shared can't live in a shared *section* —
`check-conventions` forbids a section importing another section. So they
moved to the shared layers:
- `StepperProgressRail`, `StepperSwipeDeck` → `ui/`
- `useBreakpointStepSync` → `platform/motion`

Both consumer pages repointed (`@/sections/home-stepper`,
`@/sections/product-stepper`); the row-gap allowlist in
`check-conventions.mjs` updated to the new paths; explanatory comments
stripped across the moved files (CSS-in-template comments and `'use
client'` kept).

Pure reorganization — no behavior change. typecheck + lint + build all
green.
This commit is contained in:
Abdullah.
2026-06-25 15:24:22 +05:00
committed by GitHub
parent 904d3d6bff
commit 5a657129f0
37 changed files with 73 additions and 118 deletions
@@ -102,8 +102,8 @@ const ROW_GAP_MULTI_AXIS_FILES = new Set([
'sections/pricing-plans/PricingBoard.tsx',
'sections/problem/Problem.tsx',
'sections/releases-feed/ReleasesFeed.tsx',
'sections/stepper/ProductStepper.tsx',
'sections/stepper/Stepper.tsx',
'sections/product-stepper/ProductStepper.tsx',
'sections/home-stepper/HomeStepper.tsx',
'sections/testimonials/PartnerTestimonialsCarousel.tsx',
'sections/testimonials/TestimonialsCarousel.tsx',
'sections/trusted-by/TrustedBy.tsx',
@@ -11,7 +11,7 @@ import { HomeHero } from '@/sections/home-hero';
import { Menu } from '@/sections/menu';
import { Testimonials } from '@/sections/testimonials';
import { Problem } from '@/sections/problem';
import { Stepper } from '@/sections/stepper';
import { HomeStepper } from '@/sections/home-stepper';
import { ThreeCards } from '@/sections/three-cards';
import { TrustedBy } from '@/sections/trusted-by';
@@ -35,7 +35,7 @@ export default async function HomePage({
<TrustedBy />
<Problem />
<ThreeCards />
<Stepper />
<HomeStepper />
<FeatureCards />
<Helped />
<Testimonials />
@@ -15,7 +15,7 @@ import { Menu } from '@/sections/menu';
import { ProductDemo } from '@/sections/product-demo';
import { ProductFeature } from '@/sections/product-feature';
import { ProductHero } from '@/sections/product-hero';
import { ProductStepper } from '@/sections/stepper';
import { ProductStepper } from '@/sections/product-stepper';
import { ProductThreeCards } from '@/sections/three-cards';
import { TrustedBy } from '@/sections/trusted-by';
@@ -17,3 +17,4 @@ export { useScheduledOnScroll } from './use-scheduled-on-scroll';
export { usePrefersReducedMotion } from './use-prefers-reduced-motion';
export { useScrollProgress } from './use-scroll-progress';
export { scheduleIdleTask } from './schedule-idle-task';
export { useBreakpointStepSync } from './use-breakpoint-step-sync';
@@ -2,8 +2,6 @@
import { useEffect, useRef, useState } from 'react';
// Keeps the mobile swipe index continuous with the desktop scroll position
// when the viewport crosses the breakpoint mid-interaction.
export function useBreakpointStepSync(
isMdUp: boolean,
scrollProgress: number,
@@ -3,15 +3,18 @@
import { styled } from '@linaria/react';
import { useRef, useState } from 'react';
import { ScrollProgressEffect, useMediaQuery } from '@/platform/motion';
import {
ScrollProgressEffect,
useBreakpointStepSync,
useMediaQuery,
} from '@/platform/motion';
import { BREAKPOINT_PX, mediaUp, spacing } from '@/tokens';
import { SectionShell } from '@/ui';
import { StepperLottie } from './StepperLottie';
import { StepperSteps } from './StepperSteps';
import { StepperVisualFrame } from './StepperVisualFrame';
import { STEPPER_STEPS } from './stepper.data';
import { useBreakpointStepSync } from './use-breakpoint-step-sync';
import { HomeStepperLottie } from './components/HomeStepperLottie';
import { HomeStepperSteps } from './components/HomeStepperSteps';
import { HomeStepperVisualFrame } from './components/HomeStepperVisualFrame';
import { STEPPER_STEPS } from './data/stepper.data';
const ScrollStage = styled.div`
display: grid;
@@ -59,7 +62,7 @@ const VisualMeasure = styled.div`
}
`;
export function Stepper() {
export function HomeStepper() {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isMdUp = useMediaQuery(`(min-width: ${BREAKPOINT_PX.md}px)`);
const [scrollProgress, setScrollProgress] = useState(0);
@@ -82,7 +85,7 @@ export function Stepper() {
scrollContainerRef={scrollContainerRef}
/>
<ScrollStage ref={scrollContainerRef}>
<StepperSteps
<HomeStepperSteps
activeStepIndex={activeStepIndex}
layoutMode={isMdUp ? 'scroll' : 'swipe'}
localProgress={localProgress}
@@ -91,9 +94,9 @@ export function Stepper() {
/>
<VisualColumn>
<VisualMeasure>
<StepperVisualFrame>
<StepperLottie scrollProgress={visualScrollProgress} />
</StepperVisualFrame>
<HomeStepperVisualFrame>
<HomeStepperLottie scrollProgress={visualScrollProgress} />
</HomeStepperVisualFrame>
</VisualMeasure>
</VisualColumn>
</ScrollStage>
@@ -5,7 +5,7 @@ import { styled } from '@linaria/react';
import { spacing } from '@/tokens';
import { useDotLottieScrollSync } from './use-dot-lottie-scroll-sync';
import { useDotLottieScrollSync } from '../utils/use-dot-lottie-scroll-sync';
const LOTTIE_SRC = '/lottie/stepper/stepper.lottie';
@@ -18,7 +18,11 @@ const LottieSlot = styled.div`
width: 100%;
`;
export function StepperLottie({ scrollProgress }: { scrollProgress: number }) {
export function HomeStepperLottie({
scrollProgress,
}: {
scrollProgress: number;
}) {
const playerRefCallback = useDotLottieScrollSync(scrollProgress);
return (
@@ -5,15 +5,10 @@ import { styled } from '@linaria/react';
import { type CSSProperties } from 'react';
import { mediaUp, spacing } from '@/tokens';
import { Body, Heading } from '@/ui';
import { Body, Heading, StepperProgressRail, StepperSwipeDeck } from '@/ui';
import { StepperProgressRail } from './StepperProgressRail';
import { StepperSwipeDeck } from './StepperSwipeDeck';
import { type StepperStep } from './stepper.data';
import { type StepperStep } from '../data/stepper.data';
// Each step holds fully visible for most of its scroll chapter, then hands
// off to the next over the remaining fraction (values ported from the
// authored lottie chapter boundaries).
const HOLD_FRACTIONS = [240 / 285, (880 - 285) / (925 - 285), 1];
const StepsRoot = styled.div`
@@ -119,7 +114,7 @@ const computeStepStyle = (
return { opacity: 0, transform: 'translateY(40vh)' };
};
export type StepperStepsProps = {
export type HomeStepperStepsProps = {
activeStepIndex: number;
layoutMode: 'scroll' | 'swipe';
localProgress: number;
@@ -127,13 +122,13 @@ export type StepperStepsProps = {
steps: readonly StepperStep[];
};
export function StepperSteps({
export function HomeStepperSteps({
activeStepIndex,
layoutMode,
localProgress,
onMobileStepIndexChange,
steps,
}: StepperStepsProps) {
}: HomeStepperStepsProps) {
const { i18n } = useLingui();
const renderStepContent = (step: StepperStep) => (
@@ -1,14 +1,11 @@
import { styled } from '@linaria/react';
import { HalftoneImageBackdrop } from '@/platform/visuals/rigs/HalftoneImageBackdrop';
import { STEPPER_BACKDROP } from './stepper-backdrop-config';
import { STEPPER_BACKDROP } from '../data/stepper-backdrop-config';
import { type ReactNode } from 'react';
import { color } from '@/tokens';
// The notched frame that hosts the stepper visual: halftone backdrop on
// the graphite stage behind, content above, hairline border tracing the
// same path.
const FRAME_MASK_PATH =
'M4 0H668a4 4 0 0 1 4 4V701a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V499L28 462V215L0 178V4a4 4 0 0 1 4-4Z';
@@ -48,7 +45,7 @@ const FrameBorder = styled.svg`
z-index: 2;
`;
export function StepperVisualFrame({ children }: { children: ReactNode }) {
export function HomeStepperVisualFrame({ children }: { children: ReactNode }) {
return (
<FrameRoot data-stepper-visual-pointer-root="">
<MaskedBackdrop aria-hidden data-illustration="stepper-backdrop">
@@ -2,10 +2,6 @@ import { paletteColorNumber } from '@/tokens';
import { type HalftoneImageBackdropProps } from '@/platform/visuals/rigs/HalftoneImageBackdrop';
// The authored stepper backdrop: fog dashes over the worker image inside
// the graphite frame, brightening to white where the pointer hovers (the
// hover field tracks the whole visual area, not just the canvas). The old
// hook skips the visual entirely under reduced motion.
export const STEPPER_BACKDROP: Pick<
HalftoneImageBackdropProps,
'imageUrl' | 'settings'
@@ -0,0 +1 @@
export { HomeStepper } from './HomeStepper';
@@ -1,6 +1,3 @@
// The lottie timeline is authored in three chapters; these frame numbers
// are tied to the asset (stepper.lottie, 1439 frames) and must change
// together with it.
const STEP_COUNT = 3;
const STEP_1_TRANSITION_END = 285;
const STEP_2_TRANSITION_END = 925;
@@ -3,18 +3,18 @@
import { styled } from '@linaria/react';
import { useRef, useState } from 'react';
import { ScrollProgressEffect, useMediaQuery } from '@/platform/motion';
import {
ScrollProgressEffect,
useBreakpointStepSync,
useMediaQuery,
} from '@/platform/motion';
import { BREAKPOINT_PX, mediaUp, spacing } from '@/tokens';
import { SectionShell } from '@/ui';
import { ProductStepperContent } from './ProductStepperContent';
import { PRODUCT_STEPPER_STEPS } from './product-stepper-data';
import { ProductStepperVisual } from './ProductStepperVisual';
import { useBreakpointStepSync } from './use-breakpoint-step-sync';
import { ProductStepperContent } from './components/ProductStepperContent';
import { PRODUCT_STEPPER_STEPS } from './data/product-stepper-data';
import { ProductStepperVisual } from './components/ProductStepperVisual';
// The 300vh scroll track the sticky content and visual ride (the old
// site put the height on the section; the rhythm shell owns padding, so
// the track is the inner stage here).
const ScrollTrack = styled.div`
${mediaUp('md')} {
height: 300vh;
@@ -1,5 +1,3 @@
// The data-model canvas's mini glyphs (authored scene artwork, verbatim).
// Field/header icons are looked up by key so the scene data stays pure.
export type DataModelFieldIcon =
| 'apps'
| 'building'
@@ -6,7 +6,10 @@ import { useState } from 'react';
import { usePointerDragPositions } from '@/platform/motion';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import { DATA_MODEL_GRAPH, type EntityConnection } from './data-model-data';
import {
DATA_MODEL_GRAPH,
type EntityConnection,
} from '../data/data-model-data';
import { DATA_MODEL_ICONS } from './DataModelIcons';
import { DrawEdge } from './DrawEdge';
import { STEPPER_SHELL_CHROME } from './ProductStepperShell';
@@ -177,9 +180,6 @@ function getCardCenter(
};
}
// The draggable entity graph: cards reposition under the pointer and the
// SVG connectors track their centers (positions stay React state because
// the edges are rendered paths).
export function DataModelVisual({ active }: { active: boolean }) {
const {
canvasHandlers,
@@ -12,8 +12,6 @@ export type DrawEdgeProps = {
to: Point;
};
// One elbow connector between two card centers: near-aligned points draw
// straight, others elbow in the requested order; endpoints get dots.
export function DrawEdge({
circleRadius = 2,
elbow = 'vertical-first',
@@ -1,6 +1,5 @@
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
// The layout editor's mini glyphs (authored scene artwork, verbatim).
const inks = PRODUCT_STEPPER_SCENE.layout;
export type LayoutFieldIconType =
@@ -6,8 +6,8 @@ import {
type PointerEvent as ReactPointerEvent,
} from 'react';
import { LAYOUT_CHROME } from './layout-chrome';
import { LAYOUT_EDITOR_CONTENT } from './layout-data';
import { LAYOUT_CHROME } from '../data/layout-chrome';
import { LAYOUT_EDITOR_CONTENT } from '../data/layout-data';
import { LAYOUT_GLYPHS } from './LayoutIcons';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
@@ -85,12 +85,8 @@ const {
WidgetValue,
} = LAYOUT_CHROME;
// One field row is 22px tall; vertical drag distance converts to reorder
// steps at that grain (verbatim mechanism — discrete splices).
const ROW_STEP_PX = 22;
// The layout editor: glass panels over the record page, with the fields
// list reorderable by drag and visibility toggled per row.
export function LayoutVisual({ active }: { active: boolean }) {
const [fields, setFields] = useState(LAYOUT_EDITOR_CONTENT.fields);
const [draggingId, setDraggingId] = useState<string | null>(null);
@@ -144,8 +140,6 @@ export function LayoutVisual({ active }: { active: boolean }) {
};
const handleDragEnd = () => setDraggingId(null);
// A stolen capture (alt-tab, gestures) must settle the drag too; the
// event bubbles up from the captured row.
const handleLostCapture = () => {
if (draggingId) {
setDraggingId(null);
@@ -7,14 +7,19 @@ import { type CSSProperties } from 'react';
import { INFORMATIVE_MARKS } from '@/icons';
import { color, fontFamily, mediaUp, radius, spacing } from '@/tokens';
import { Body, Eyebrow, Heading, HeadingPair } from '@/ui';
import {
Body,
Eyebrow,
Heading,
HeadingPair,
StepperProgressRail,
StepperSwipeDeck,
} from '@/ui';
import {
PRODUCT_STEPPER_STEPS,
type ProductStepperStep,
} from './product-stepper-data';
import { StepperProgressRail } from './StepperProgressRail';
import { StepperSwipeDeck } from './StepperSwipeDeck';
} from '../data/product-stepper-data';
const ContentRoot = styled.div`
display: grid;
@@ -45,9 +50,6 @@ const StepsColumn = styled.div`
}
`;
// The intro takes the house grammar (24px eyebrow-to-heading via this
// gap, 12px heading-to-body via HeadingPair) — a ratified divergence
// from the old site's uniform 8px stack.
const IntroBlock = styled.div`
display: grid;
grid-template-columns: 1fr;
@@ -115,9 +117,6 @@ const StepHeading = styled.span`
font-family: ${fontFamily('sans')};
`;
// The old choreography, verbatim: passed steps sit settled, the upcoming
// step rises 300px through its hand-off window at 0.4 opacity, later
// steps wait below.
const computeStepStyle = (
stepNumber: number,
activeStepIndex: number,
@@ -156,8 +155,6 @@ export function ProductStepperContent({
return (
<>
<StepRowHeader>
{/* The old site inked inactive icons white too (invisible on the
light scheme by construction) ported as-is. */}
<StepIconBox data-active={isActive ? '' : undefined}>
<Icon color={color('white')} sizePx={14} />
</StepIconBox>
@@ -105,9 +105,6 @@ const SvgLayer = styled.svg`
width: 100%;
`;
// The white mini-app window every product stepper visual floats in: logo
// chip + title bar, then the bordered editing canvas with an SVG overlay
// for connectors.
function Shell({
active,
children,
@@ -10,7 +10,7 @@ import { LayoutVisual } from './LayoutVisual';
import {
PRODUCT_STEPPER_STEPS,
type ProductStepperVisualKey,
} from './product-stepper-data';
} from '../data/product-stepper-data';
import { ProductStepperVisualFrame } from './ProductStepperVisualFrame';
import { WorkflowVisual } from './WorkflowVisual';
@@ -68,8 +68,6 @@ const SlideInner = styled.div`
width: 100%;
`;
// The sticky stage: one slide per step, crossfading as the active step
// changes; only the active slide takes the pointer.
export function ProductStepperVisual({
activeStepIndex,
}: {
@@ -3,9 +3,6 @@ import { styled } from '@linaria/react';
import NextImage from 'next/image';
import { type ReactNode } from 'react';
// The product stepper's stage: its own authored artwork (the dark
// notched shape with the dash pattern over it), ported verbatim — unlike
// the home frame, the stage here is baked into the two webps.
const BACKGROUND_SRC = '/images/product/stepper/background.webp';
const SHAPE_SRC = '/images/product/stepper/background-shape.webp';
@@ -1,6 +1,5 @@
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
// The workflow nodes' mini glyphs (authored scene artwork, verbatim).
export type WorkflowIconName =
| 'playlistAdd'
| 'plus'
@@ -66,7 +65,6 @@ function IconRepeat() {
);
}
// The send node draws in its own amber ink, verbatim.
function IconSend() {
return (
<svg
@@ -7,8 +7,8 @@ import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-s
import { DrawEdge } from './DrawEdge';
import { STEPPER_SHELL_CHROME } from './ProductStepperShell';
import { useWorkflowAnimation } from './use-workflow-animation';
import { WORKFLOW_GRAPH } from './workflow-data';
import { useWorkflowAnimation } from '../utils/use-workflow-animation';
import { WORKFLOW_GRAPH } from '../data/workflow-data';
import { WORKFLOW_GLYPHS } from './WorkflowIcons';
const shell = PRODUCT_STEPPER_SCENE.shell;
@@ -123,8 +123,6 @@ function getNodeCenter(position: { x: number; y: number }): {
return { x: position.x + NODE_WIDTH / 2, y: position.y + NODE_HEIGHT / 2 };
}
// The authored loop-back connector from the iterator out to the email
// node, redrawn from live node positions (verbatim path math).
function IteratorLoopPath({ positions }: { positions: NodePositions }) {
const iteratorPosition = positions.iterator;
const emailPosition = positions.email;
@@ -172,9 +170,6 @@ function IteratorLoopPath({ positions }: { positions: NodePositions }) {
);
}
// The draggable workflow run: nodes reposition under the pointer, edges
// and the iteration label track them, and the active step beat ticks
// check badges down the sequence while the slide is active.
export function WorkflowVisual({ active }: { active: boolean }) {
const {
canvasHandlers,
@@ -3,7 +3,7 @@ import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-step
import {
type DataModelFieldIcon,
type DataModelHeaderIcon,
} from './DataModelIcons';
} from '../components/DataModelIcons';
export type EntityTone = keyof typeof PRODUCT_STEPPER_SCENE.entityTones;
@@ -25,7 +25,6 @@ export type EntityConnection = {
to: string;
};
// Mock fiction entity graph (product-screenshot copy, English).
export const DATA_MODEL_GRAPH: {
connections: EntityConnection[];
entities: EntityDefinition[];
@@ -2,8 +2,6 @@ import { styled } from '@linaria/react';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
// The layout editor's glassmorphic panel chrome (verbatim from the old
// layout-styles, gathered behind one record like RECORD_PANEL_CHROME).
const shell = PRODUCT_STEPPER_SCENE.shell;
const layout = PRODUCT_STEPPER_SCENE.layout;
@@ -3,7 +3,7 @@ import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-step
import {
type LayoutFieldIconType,
type LayoutNavIconType,
} from './LayoutIcons';
} from '../components/LayoutIcons';
type NavTint = keyof typeof PRODUCT_STEPPER_SCENE.navTints;
@@ -26,7 +26,6 @@ export type LayoutNavItemDefinition = {
suffix?: string;
};
// Mock fiction layout editor content (product-screenshot copy, English).
export const LAYOUT_EDITOR_CONTENT: {
fields: LayoutFieldDefinition[];
navItems: LayoutNavItemDefinition[];
@@ -1,4 +1,4 @@
import { type WorkflowIconName } from './WorkflowIcons';
import { type WorkflowIconName } from '../components/WorkflowIcons';
export type WorkflowNodeDefinition = {
badge?: string;
@@ -21,8 +21,6 @@ const TRUNK_X = 55;
const RIGHT_X = 200;
const LEFT_X = 5;
// Mock fiction workflow graph (product-screenshot copy, English). The
// animation activates node prefixes on a fixed beat, then loops.
export const WORKFLOW_GRAPH: {
animationSequence: string[];
edges: WorkflowEdgeDefinition[];
@@ -1,2 +1 @@
export { Stepper } from './Stepper';
export { ProductStepper } from './ProductStepper';
@@ -4,11 +4,8 @@ import { useEffect, useState } from 'react';
import { useTimeoutRegistry } from '@/app-preview/stage/use-timeout-registry';
import { WORKFLOW_GRAPH } from './workflow-data';
import { WORKFLOW_GRAPH } from '../data/workflow-data';
// Activates the workflow nodes one beat at a time while the visual is
// active, then loops; deactivating resets. Self-rescheduling tick on the
// house timeout registry (auto-cleanup, fake-timer-testable).
export function useWorkflowAnimation(active: boolean): Set<string> {
const [activeNodes, setActiveNodes] = useState<Set<string>>(new Set());
const timeouts = useTimeoutRegistry();
@@ -84,8 +84,6 @@ const PillTrack = styled.div`
}
`;
// Fill height rides a CSS variable, so the only inline style is a custom
// property; the first render is deterministic (0%) on server and client.
const PillFill = styled.div`
background-color: ${color('blue')};
border-radius: ${radius(8)};
@@ -115,8 +113,6 @@ const StepLabel = styled.div`
export type StepperProgressRailProps = {
activeStepIndex: number;
// Inactive dot ink; the product stepper runs a stronger one than the
// home default.
inactiveColor?: string;
localProgress: number;
stepCount: number;
@@ -30,10 +30,8 @@ const Track = styled.div`
`;
const releasePointer = (event: React.PointerEvent<HTMLDivElement>): void => {
try {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// pointer was not captured
}
};
+8
View File
@@ -40,3 +40,11 @@ export { SectionStack } from './SectionStack';
export { VerticalDivider } from './VerticalDivider';
export { SectionShell, type SectionShellProps } from './SectionShell';
export { Signoff, type SignoffProps } from './Signoff';
export {
StepperProgressRail,
type StepperProgressRailProps,
} from './StepperProgressRail';
export {
StepperSwipeDeck,
type StepperSwipeDeckProps,
} from './StepperSwipeDeck';