Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/sim/app/(landing)/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost
- **No heavy client libraries above the fold.** No animation frameworks (framer-motion etc.), no ReactFlow, no chart libs in the initial bundle. If a below-fold section truly needs one, load it with `next/dynamic` and a dimension-stable placeholder.
- **Images via `next/image` always.** The LCP element (logo or hero visual) gets `priority`; everything below the fold lazy-loads (the default). Every image has explicit `width`/`height` - zero layout shift.
- **Prefer CSS over JS.** Hover states, transitions, marquees, and reveal effects in CSS (`transition-*`, `animation`) rather than scroll listeners or animation libraries. Decorative motion respects `prefers-reduced-motion`.
- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time on the server or deferred to a tiny client island.
- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time or deferred to a client island. A `cookies()`/`headers()`/`unstable_noStore()` call anywhere in the tree - including the root `app/layout.tsx` - silently overrides every page's `revalidate` and forces the whole app dynamic. If a marketing page builds as `ƒ` instead of `○`/`●` (check `bun run build`'s route table), look upstream, not just at the page itself.
- **Reserve space for everything.** Fixed dimensions or aspect ratios on all media, embeds, and async content. CLS budget is effectively zero.
- **Decorative canvases and animations are non-interactive.** A hand-built product-demo animation or embedded ReactFlow canvas is presentation only - no drag handlers, no `nodesDraggable`/`panOnDrag`/`elementsSelectable` on ReactFlow. A visitor should never be able to click or drag a decorative element.
- **Lazy-mount a heavy client island's second occurrence.** If the same animated component appears twice on a page, only the first (usually the hero) loads eagerly - the rest go through a small `'use client'` mount wrapper built on the shared `hooks/use-lazy-mount.ts` hook: `next/dynamic(..., { ssr: false })` gated by the hook's `IntersectionObserver`. See `components/product-demo/components/product-demo-visual-mount/` for the reference pattern, and `.claude/rules/sim-imports.md` for the barrel-cleanup step that must come with it.
- **Don't prefetch authenticated-app routes from an always-visible CTA.** `<Link>` prefetches its target route's JS once it's in the viewport - a navbar/hero CTA to `/signup` or `/login` is always in view, so it downloads that route's bundle on every pageview. Pass `prefetch={false}` there. Leave the default on CTAs that only enter the viewport on scroll (prefetch-on-approach is the desired behavior there).

## SEO

Expand Down Expand Up @@ -72,6 +75,7 @@ Follow `.claude/rules/constitution.md` exactly: Sim is "the open-source AI works
├── page.tsx # route entry: metadata + <Landing />
├── landing.tsx # root composition: <main> section order
├── workflows/ # a platform route: page.tsx (metadata) + workflows.tsx (config + shell)
├── hooks/ # cross-page client hooks (useLazyMount, …) - bare files, no folder/barrel
└── components/
├── index.ts # top barrel
├── navbar/{navbar.tsx, index.ts, components/<chip>/…} # <header><nav>: wordmark, dropdowns, stars, auth chips
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ export function ContentAuthorPage({
width={600}
height={315}
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[var(--text-muted)] text-xs'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ export function ContentIndexPage({
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
priority={index < 3}
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
Expand Down Expand Up @@ -158,7 +157,6 @@ export function ContentIndexPage({
fill
sizes='140px'
className='object-cover'
unoptimized
/>
</div>
</Link>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export function ContentPostPage({
sizes='(max-width: 768px) 100vw, 450px'
priority
itemProp='image'
unoptimized
/>
</div>
</div>
Expand Down Expand Up @@ -142,7 +141,6 @@ export function ContentPostPage({
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
loading='lazy'
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function HeroCta() {
</ChipLink>
<ChipLink
href='/signup'
prefetch={false}
className={cn(CTA_SIZE, 'border border-[var(--border-1)] max-sm:justify-center')}
>
Sign up
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ const CHROME_INTERIOR = { width: 1024, height: 721 } as const
* chat|stage divider is covered by this region, so the divider users see is
* the live `border-l`, appearing exactly as it does in the product.
*
* The chat pane stays `pointer-events-none` (decorative); the workflow pane is
* interactive - blocks are draggable. Remounting the stage per cycle
* (`key={cycleId}`) resets dragged positions and build state.
* Both panes stay `pointer-events-none` (decorative, matching the hero's
* `aria-hidden` frame) - blocks are static. Remounting the stage per cycle
* (`key={cycleId}`) resets build state.
*
* Under `prefers-reduced-motion` the loop never starts: the finished exchange,
* open stage, and fully-built workflow render statically (still draggable).
* open stage, and fully-built workflow render statically.
*/
export function HeroPlatformLoop() {
const regionRef = useRef<HTMLDivElement>(null)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { type CSSProperties, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { StageBlockCard } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-block-card'
import {
Expand All @@ -22,43 +22,26 @@ interface HeroWorkflowStageProps {
builtCount: number
}

type Positions = Record<string, { x: number; y: number }>

const initialPositions = (): Positions =>
Object.fromEntries(STAGE_BLOCKS.map((b) => [b.id, { x: b.x, y: b.y }]))

const STAGE_BLOCKS_BY_ID = new Map(STAGE_BLOCKS.map((b) => [b.id, b]))

/**
* The hero window's live workflow canvas - the right-pane counterpart of the
* chat loop. Blocks pop in one by one as `builtCount` advances (staggered
* scale/fade entrances, edges stroke-draw once both endpoints exist), and every
* block is DRAGGABLE: pointer-drag updates its position (scaled to design
* space) and its edges follow live. The edge SVG is `overflow-visible` -
* SVGs clip at their viewport by default, which cut the lines the moment a
* dragged block left the design-canvas bounds while the HTML block cards
* escaped freely. Positions reset when the parent remounts the stage for a
* new loop pass (`key={cycleId}`).
* scale/fade entrances, edges stroke-draw once both endpoints exist) at their
* fixed positions. The edge SVG is `overflow-visible` - SVGs clip
* at their viewport by default, which would cut the lines if a block ever sat
* outside the design-canvas bounds.
*
* Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
* draggable - `pointer-events-none`, matching the rest of the hero animation.
*
* Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful
* icon-tile + rows card body) in a card shell with vertical-flow handle nubs
* (top in / bottom out), matching the real editor's vertical layout.
*/
export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
const [positions, setPositions] = useState<Positions>(initialPositions)
const containerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(MAX_STAGE_SCALE)
const dragRef = useRef<{
id: string
pointerId: number
startX: number
startY: number
originX: number
originY: number
/** Total design-px -> visual-px factor for the dragged block (this stage's
* fit scale x the platform loop's design-space scale), captured at grab. */
visualScale: number
} | null>(null)

// Fit the design canvas to the card: scale down when the pane narrows so the
// branch blocks never clip, capped at the full-width scale. Measures LAYOUT
Expand Down Expand Up @@ -86,43 +69,6 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
return () => ro.disconnect()
}, [])

const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, id: string) => {
if (dragRef.current) return
const pos = positions[id]
dragRef.current = {
id,
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
originX: pos.x,
originY: pos.y,
// Rendered width / design width = the block's total visual scale, all
// ancestor transforms included - no need to thread each factor through.
visualScale: e.currentTarget.getBoundingClientRect().width / BLOCK_WIDTH,
}
e.currentTarget.setPointerCapture(e.pointerId)
},
[positions]
)

const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (!drag || e.pointerId !== drag.pointerId || drag.visualScale <= 0) return
const dx = (e.clientX - drag.startX) / drag.visualScale
const dy = (e.clientY - drag.startY) / drag.visualScale
setPositions((prev) => ({
...prev,
[drag.id]: { x: drag.originX + dx, y: drag.originY + dy },
}))
}, [])

const onPointerEnd = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (!drag || e.pointerId !== drag.pointerId) return
dragRef.current = null
}, [])

const builtIds = useMemo(
() => new Set(STAGE_BLOCKS.slice(0, builtCount).map((b) => b.id)),
[builtCount]
Expand Down Expand Up @@ -162,8 +108,8 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
const target = STAGE_BLOCKS_BY_ID.get(to)
if (!source || !target) return null
const visible = builtIds.has(from) && builtIds.has(to)
const s = handleAnchors(source, positions[from]).out
const t = handleAnchors(target, positions[to]).in
const s = handleAnchors(source).out
const t = handleAnchors(target).in
return (
<path
key={`${from}-${to}`}
Expand All @@ -181,20 +127,14 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {

{STAGE_BLOCKS.map((block) => {
const built = builtIds.has(block.id)
const pos = positions[block.id]
return (
<div
key={block.id}
className={cn(
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
'transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
built ? 'scale-100 opacity-100' : 'pointer-events-none scale-[0.94] opacity-0'
'pointer-events-none absolute transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
)}
style={{ left: pos.x, top: pos.y, width: BLOCK_WIDTH }}
onPointerDown={(e) => onPointerDown(e, block.id)}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
>
<StageBlockCard block={block} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ export function verticalSmoothStep(sx: number, sy: number, tx: number, ty: numbe
].join(' ')
}

/** Handle anchor points for a block at a live position. */
export function handleAnchors(block: BlockDef, pos: { x: number; y: number }) {
/** Handle anchor points for a block at its fixed position. */
export function handleAnchors(block: BlockDef) {
return {
out: { x: pos.x + BLOCK_WIDTH / 2, y: pos.y + blockHeight(block) },
in: { x: pos.x + BLOCK_WIDTH / 2, y: pos.y },
out: { x: block.x + BLOCK_WIDTH / 2, y: block.y + blockHeight(block) },
in: { x: block.x + BLOCK_WIDTH / 2, y: block.y },
}
}
1 change: 0 additions & 1 deletion apps/sim/app/(landing)/components/hero/components/index.ts

This file was deleted.

6 changes: 3 additions & 3 deletions apps/sim/app/(landing)/components/hero/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ import { TrustedBy } from '@/app/(landing)/components/trusted-by'
* SIDEBAR
* remains visible from the shot: the {@link HeroPlatformLoop} island overlays
* the container interior (full-width chat that stages the workflow pane in,
* replaying the conversation with the goo ThinkingLoader; blocks stay
* draggable), inset a hair INSIDE the shot's own baked outlines so the visible
* chrome is the real UI's pixels - never re-drawn.
* replaying the conversation with the goo ThinkingLoader), inset a hair INSIDE
* the shot's own baked outlines so the visible chrome is the real UI's pixels
* - never re-drawn.
* The frame is `1300/720` and the window `1080/620` at `83.08%` width, centered
* - matching cursor.com's hero media proportions, with backdrop showing on all
* four sides. Decorative, `aria-hidden`; the `--surface-3` fill remains as the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,13 @@ function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps)
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{ type: 'previewEdge' }}
elementsSelectable={false}
nodesDraggable
nodesDraggable={false}
nodesConnectable={false}
zoomOnScroll={false}
zoomOnDoubleClick={false}
panOnScroll={false}
zoomOnPinch={false}
panOnDrag
panOnDrag={false}
preventScrolling={false}
autoPanOnNodeDrag={false}
proOptions={PRO_OPTIONS}
Expand All @@ -136,7 +136,8 @@ function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps)
}

/**
* Lightweight ReactFlow canvas displaying an interactive workflow preview.
* Lightweight ReactFlow canvas displaying a static workflow preview -
* decorative, so panning, dragging, zooming, and selecting are all disabled.
* The key on workflow.id forces a clean remount on switch - instant fitView,
* no timers, no flicker.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,24 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'

/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */
const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]'

/**
* Load the preview chunk a little before it scrolls into view so it's ready by
* the time the user reaches it, without paying for it on initial load.
*/
const PRELOAD_ROOT_MARGIN = '400px'

/**
* Client mount for the {@link LandingPreview} - the heavy, animated workspace
* island (framer-motion + reactflow). Isolated here so the sections that show it
* stay Server Components: only this leaf is `'use client'`.
*
* Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in
* the server-rendered HTML, and **gated on viewport proximity**: the chunk only
* downloads once an {@link IntersectionObserver} reports the mount is near the
* viewport, so the below-the-fold previews don't pull the heavy bundle into the
* initial homepage load. A dimension-stable placeholder (the preview's exact
* `aspect-[1116/615]` footprint, filled with the canvas surface) holds the space
* before and during load, so there is zero layout shift or flash.
* the server-rendered HTML, and gated on viewport proximity via
* {@link useLazyMount} so the below-the-fold previews don't pull the heavy
* bundle into the initial homepage load. A dimension-stable placeholder (the
* preview's exact `aspect-[1116/615]` footprint, filled with the canvas
* surface) holds the space before and during load, so there is zero layout
* shift or flash.
*/
const LandingPreview = dynamic(
() =>
Expand All @@ -47,28 +41,7 @@ interface LandingPreviewMountProps {
}

export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) {
const ref = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)

useEffect(() => {
if (inView) return
// Graceful degradation: without IntersectionObserver support, load eagerly
// rather than leave the preview stuck on its placeholder.
if (typeof IntersectionObserver === 'undefined') {
setInView(true)
return
}
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setInView(true)
},
{ rootMargin: PRELOAD_ROOT_MARGIN }
)
observer.observe(el)
return () => observer.disconnect()
}, [inView])
const { ref, inView } = useLazyMount('400px')

return (
<div ref={ref}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function MobileNav({ stars }: MobileNavProps) {

return (
<div className='ml-auto flex items-center gap-2 lg:hidden'>
<ChipLink variant='primary' href='/signup'>
<ChipLink variant='primary' href='/signup' prefetch={false}>
Sign up
</ChipLink>
<button
Expand Down Expand Up @@ -154,6 +154,7 @@ export function MobileNav({ stars }: MobileNavProps) {
href='/login'
fullWidth
flush
prefetch={false}
className='h-[40px] justify-center border border-[var(--border-1)] [&>span]:flex-none'
onClick={() => setOpen(false)}
>
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/app/(landing)/components/navbar/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@ export function Navbar({ stars, logoOnly = false }: NavbarProps) {
</div>

<div className='ml-auto hidden items-center gap-1 lg:flex'>
<ChipLink href='/login'>Log in</ChipLink>
<ChipLink href='/login' prefetch={false}>
Log in
</ChipLink>
<ChipLink href='/demo' className='border border-[var(--border-1)]'>
Contact sales
</ChipLink>
<ChipLink variant='primary' href='/signup'>
<ChipLink variant='primary' href='/signup' prefetch={false}>
Sign up
</ChipLink>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ProductDemoVisualMount } from './product-demo-visual-mount'
Loading
Loading