chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,234 @@
'use client'
import { useEffect, useState } from 'react'
import { cn, Tooltip } from '@sim/emcn'
import {
ArrowRight,
ArrowUp,
Copy,
Mic,
Paperclip,
Plus,
Slash,
ThumbsDown,
ThumbsUp,
} from 'lucide-react'
import { ThinkingLoader } from '@/components/ui'
import { HERO_TOOLTIP_OFFSET } from '@/app/(landing)/components/hero/components/hero-platform-loop/sidebar-hotspots'
/** The conversation the loop plays - mirrors the seeded capture chat. */
const USER_MESSAGE = 'When a new lead signs up, enrich it with company data and post it to #sales.'
const REPLY_MESSAGE =
"On it. I'll build a workflow that enriches each new signup with firmographics, scores it, and posts a summary to your #sales channel in Slack."
const REPLY_WORDS = REPLY_MESSAGE.split(' ')
/** Word-reveal cadence for the streamed reply. */
const STREAM_WORD_MS = 55
/** Follow-up suggestions shown once the reply completes, like the real chat. */
const FOLLOW_UPS = [
'Run a test with a sample lead',
'Deploy the workflow',
'Add lead scoring criteria',
] as const
/** Where the chat pane is within one loop pass. */
export type HeroChatPhase = 'idle' | 'user' | 'thinking' | 'reply'
interface HeroChatLoopProps {
/** Current phase, driven by the parent {@link HeroPlatformLoop} clock. */
phase: HeroChatPhase
/** True during the brief fade-out before the cycle restarts. */
fading: boolean
}
/**
* The Mothership chat pane of the hero's live layer - purely presentational;
* the loop clock lives in `HeroPlatformLoop` so the chat and the workflow
* stage animate off one timeline. Replays one exchange: the user message
* slides in, the goo {@link ThinkingLoader} cycles while the Mothership
* thinks, the reply lands with its action row.
*
* Visuals mirror the real chat pane (`--bg` column, grey user bubble, bare
* reply text, the real composer chrome - 28px round icon buttons, mic + the
* `#808080` send disc on the right cluster) so the seam with the surrounding
* screenshot is invisible. The reply STREAMS in word by word (the way the
* real Mothership streams its responses); once the text completes, the
* "Suggested follow-ups" block (the real special-tags markup: numbered
* `--divider`-ruled rows with a trailing arrow) and the action row land
* together; under `prefers-reduced-motion` it appears whole.
* The content column is centered and capped (like the real full-width
* MothershipChat) so it reads right both full-width (stage collapsed) and at
* half width (stage open). Only the conversation fades on reset - the pane
* and composer are persistent chrome.
*/
export function HeroChatLoop({ phase, fading }: HeroChatLoopProps) {
const showUser = phase !== 'idle'
const showThinking = phase === 'thinking'
const showReply = phase === 'reply'
const [revealedWords, setRevealedWords] = useState(0)
// Stream the reply word by word while the phase holds on 'reply'; any other
// phase (the next cycle's reset) rewinds it for the following pass. The
// count derives from ELAPSED time, not tick count, so throttled background
// tabs catch up in chunks instead of stalling mid-sentence.
useEffect(() => {
if (!showReply) {
setRevealedWords(0)
return
}
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
let interval: ReturnType<typeof setInterval> | null = null
const stream = () => {
const startedAt = performance.now()
interval = setInterval(() => {
const elapsed = performance.now() - startedAt
const n = Math.min(Math.floor(elapsed / STREAM_WORD_MS) + 1, REPLY_WORDS.length)
setRevealedWords(n)
if (n >= REPLY_WORDS.length && interval) clearInterval(interval)
}, STREAM_WORD_MS)
}
// Re-synced on 'change' (not just on mount) so toggling the preference
// mid-stream - e.g. HeroPlatformLoop's showFinished setting phase to
// 'reply' while it's already 'reply' - still snaps the reply to complete
// instead of leaving it mid-word until the running interval catches up.
const syncMotionPreference = () => {
if (interval) clearInterval(interval)
if (media.matches) {
setRevealedWords(REPLY_WORDS.length)
return
}
stream()
}
syncMotionPreference()
media.addEventListener('change', syncMotionPreference)
return () => {
media.removeEventListener('change', syncMotionPreference)
if (interval) clearInterval(interval)
}
}, [showReply])
const replyComplete = revealedWords >= REPLY_WORDS.length
return (
<div className='flex h-full w-full flex-col bg-[var(--bg)]'>
<div
className={cn(
'mx-auto flex min-h-0 w-full max-w-[640px] flex-1 flex-col gap-6 overflow-hidden px-6 pt-6 transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
<div
className={cn(
'max-w-[82%] self-end rounded-2xl bg-[var(--surface-3)] px-4 py-3 text-[15px] text-[var(--text-primary)] leading-[1.5] transition-[opacity,transform] duration-200 ease-out',
showUser ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'
)}
>
{USER_MESSAGE}
</div>
{showThinking && <ThinkingLoader size={26} phase labelRatio={0.58} className='mt-1' />}
<div
className={cn(
'flex flex-col gap-4 transition-opacity duration-200 ease-out',
showReply ? 'opacity-100' : 'opacity-0'
)}
>
<p className='text-[15px] text-[var(--text-primary)] leading-[1.6]'>
{REPLY_WORDS.slice(0, revealedWords).join(' ')}
</p>
<div
className={cn(
'flex flex-col gap-4 transition-opacity duration-200 ease-out',
replyComplete ? 'opacity-100' : 'opacity-0'
)}
>
<div className='flex flex-col'>
<span className='text-[var(--text-body)] text-sm'>Suggested follow-ups</span>
<div className='mt-1.5 flex flex-col'>
{FOLLOW_UPS.map((title, i) => (
<span
key={title}
className={cn(
'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left',
i > 0 && 'border-t'
)}
>
<span className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<span className='text-[var(--text-icon)] text-sm'>{i + 1}</span>
</span>
<span className='flex-1 text-[var(--text-body)] text-sm'>{title}</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
</span>
))}
</div>
</div>
<div className='flex items-center gap-3 text-[var(--text-icon)]'>
<Copy className='size-[14px]' />
<ThumbsUp className='size-[14px]' />
<ThumbsDown className='size-[14px]' />
</div>
</div>
</div>
</div>
<div className='pointer-events-auto mx-auto mb-5 w-[calc(100%-40px)] max-w-[600px] shrink-0 rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2'>
<p className='px-1.5 pt-1 text-[15px] text-[var(--text-muted)]'>Send message to Sim</p>
<div className='mt-2 flex items-center gap-1.5'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label='Add resources'
>
<Plus className='size-[16px] text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Add resources</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label='Attach file'
>
<Paperclip className='size-[16px] text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Attach file</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label='Skills'
>
<Slash className='size-[16px] text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Skills</Tooltip.Content>
</Tooltip.Root>
<span className='ml-auto flex items-center gap-1.5'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label='Voice input'
>
<Mic className='size-[16px] text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Voice input</Tooltip.Content>
</Tooltip.Root>
<span className='flex size-[28px] items-center justify-center rounded-full bg-[#808080]'>
<ArrowUp className='size-[16px] text-white' />
</span>
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,2 @@
export type { HeroChatPhase } from './hero-chat-loop'
export { HeroChatLoop } from './hero-chat-loop'
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
import { HeroStat } from '@/app/(landing)/components/hero/components/hero-stat'
import { HeroCta } from '@/app/(landing)/components/hero-cta'
import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout'
interface LandingHeroHeaderProps {
description: string
eyebrow?: ReactNode
heading: ReactNode
headingId: string
}
/**
* Shared homepage hero header geometry. Marketing routes use this component so
* the headline measure, CTA stack, and right-side global-work stat cannot drift.
*/
export function LandingHeroHeader({
description,
eyebrow,
heading,
headingId,
}: LandingHeroHeaderProps) {
return (
<div className='flex w-full items-end justify-between gap-8'>
<div className='flex min-w-0 flex-1 flex-col items-start gap-[22px] text-left'>
{eyebrow}
<h1
id={headingId}
className='max-w-[1120px] text-balance text-[64px] text-[var(--text-primary)] leading-[1.05] tracking-[-0.01em] max-sm:text-[36px] max-xl:text-[52px] [&>br]:max-sm:hidden'
>
{heading}
</h1>
<p className='w-full min-w-0 max-w-[58ch] text-pretty text-[var(--text-muted)] text-base leading-[1.5]'>
{description}
</p>
<div className={cn('max-sm:w-full', LANDING_HERO_CTA_GAP)}>
<HeroCta />
</div>
</div>
<HeroStat />
</div>
)
}
@@ -0,0 +1 @@
export { LandingHeroHeader } from './hero-header'
@@ -0,0 +1,176 @@
'use client'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import {
HeroChatLoop,
type HeroChatPhase,
} from '@/app/(landing)/components/hero/components/hero-chat-loop'
import { HeroWorkflowStage } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage'
import { SidebarHotspots } from '@/app/(landing)/components/hero/components/hero-platform-loop/sidebar-hotspots'
import { STAGE_BLOCKS } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
/**
* One pass of the synced loop, matching the REAL platform behavior: the chat
* runs FULL-WIDTH (stage collapsed, exactly like `MothershipView`'s `w-0`
* state); the user message lands and the Mothership starts thinking; the stage
* pane SLIDES IN from the right (the real `w-1/2` + `border-l` width
* transition); the workflow assembles block by block inside it; the reply
* lands once the flow is built; the scene holds, fades, and restarts.
*/
const PHASE_STARTS = { user: 500, thinking: 1400 } as const
/** The stage pane starts sliding open here (during thinking). */
const STAGE_OPEN_AT = 1900
/** Block N (build order) pops in at BUILD_START + N * BUILD_STEP. */
const BUILD_START = 2400
const BUILD_STEP = 620
const REPLY_AT = 6400
const TOTAL_MS = 12_500
const RESET_FADE_MS = 260
/**
* The workspace container's interior in the capture's design space (the shot
* is 2560x1470 at 2x, i.e. a 1280x735 CSS layout - oversized vs the 1080x620
* window so the whole UI displays at 84.4%, landing the app's native type at
* cursor.com's ~12.7px demo scale): x 249-1272, y 7-727. The live layer lays
* out at this FIXED size and scales down with the window (`transform: scale`),
* so its text and controls shrink in lockstep with the baked sidebar pixels -
* the "mini app" reading - instead of rendering at 1:1 CSS sizes and looking
* oversized next to the scaled screenshot.
*/
const CHROME_INTERIOR = { width: 1024, height: 721 } as const
/**
* The hero window's live layer - one flex region replaying the REAL Home
* two-pane over the static screenshot. The region covers the workspace
* container's interior (inset a hair inside the shot's baked chrome: the
* horizontal rules 6px from the card top/bottom (the chrome's `p-[8px]` gap is
* tightened to 6px at capture time), the container's left border at ~19.4%,
* and its right border at ~99.4%; the container renders at `6px` radius
* (overridden at capture time from the chrome's 8px so it DISPLAYS at the
* concentric ~4.9px after the 84.4% shot-to-window factor), so the region
* clips itself `rounded-[4px]` to hug the baked corner curves without
* covering them) so every visible outline is the real UI's pixels.
*
* Inside, the layout mirrors `Home`: the {@link HeroChatLoop} is a flex-1
* `--bg` column; the {@link HeroWorkflowStage} pane animates `w-0 ↔ w-1/2`
* with the real `MothershipView` width transition (200ms,
* `cubic-bezier(0.25,0.1,0.25,1)`, `border-l` only while open) - the baked
* 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.
*
* 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.
*/
export function HeroPlatformLoop() {
const regionRef = useRef<HTMLDivElement>(null)
const [phase, setPhase] = useState<HeroChatPhase>('idle')
const [stageOpen, setStageOpen] = useState(false)
const [builtCount, setBuiltCount] = useState(0)
const [fading, setFading] = useState(false)
const [cycleId, setCycleId] = useState(0)
const [scale, setScale] = useState(1)
// Track the rendered region width and scale the design-space layer to fill
// it, keeping the live layer's proportions locked to the screenshot's.
useLayoutEffect(() => {
const el = regionRef.current
if (!el) return
const measure = () => {
const w = el.getBoundingClientRect().width
if (w > 40) setScale(w / CHROME_INTERIOR.width)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
let timers: ReturnType<typeof setTimeout>[] = []
const clearScheduled = () => {
timers.forEach(clearTimeout)
timers = []
}
const showFinished = () => {
clearScheduled()
setFading(false)
setPhase('reply')
setStageOpen(true)
setBuiltCount(STAGE_BLOCKS.length)
}
const runCycle = () => {
setFading(false)
setPhase('idle')
setStageOpen(false)
setBuiltCount(0)
setCycleId((c) => c + 1)
timers = [
setTimeout(() => setPhase('user'), PHASE_STARTS.user),
setTimeout(() => setPhase('thinking'), PHASE_STARTS.thinking),
setTimeout(() => setStageOpen(true), STAGE_OPEN_AT),
...STAGE_BLOCKS.map((_, i) =>
setTimeout(() => setBuiltCount(i + 1), BUILD_START + i * BUILD_STEP)
),
setTimeout(() => setPhase('reply'), REPLY_AT),
setTimeout(() => setFading(true), TOTAL_MS - RESET_FADE_MS),
setTimeout(runCycle, TOTAL_MS),
]
}
const syncMotionPreference = () => {
clearScheduled()
if (media.matches) {
showFinished()
return
}
runCycle()
}
syncMotionPreference()
media.addEventListener('change', syncMotionPreference)
return () => {
media.removeEventListener('change', syncMotionPreference)
clearScheduled()
}
}, [])
return (
<>
<SidebarHotspots />
<div
ref={regionRef}
className='absolute top-[0.95%] right-[0.55%] bottom-[0.95%] left-[19.45%] overflow-hidden rounded-[4px]'
>
<div
className='flex origin-top-left'
style={{
width: CHROME_INTERIOR.width,
height: CHROME_INTERIOR.height,
transform: `scale(${scale})`,
}}
>
<div className='pointer-events-none relative h-full min-w-0 flex-1'>
<HeroChatLoop phase={phase} fading={fading} />
</div>
<div
className={cn(
'h-full shrink-0 overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
stageOpen ? 'w-1/2 border-l' : 'w-0 min-w-0 border-l-0'
)}
>
<HeroWorkflowStage key={cycleId} builtCount={builtCount} />
</div>
</div>
</div>
</>
)
}
@@ -0,0 +1,164 @@
'use client'
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 {
handleAnchors,
STAGE_BLOCKS,
STAGE_CANVAS,
STAGE_EDGES,
verticalSmoothStep,
} from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
import {
BLOCK_WIDTH,
type BlockDef,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/** Upper bound on the canvas render scale (the scale at the full 1300px cap). */
const MAX_STAGE_SCALE = 0.71
/** Breathing room between the canvas bounds and the card edges, in card px. */
const STAGE_MARGIN = 20
interface HeroWorkflowStageProps {
/** How many of the stage's blocks (in build order) are on canvas. */
builtCount: number
/** Blocks to stage, in build order. Defaults to the homepage's lead flow. */
blocks?: BlockDef[]
/** Source → target pairs among {@link blocks}. Defaults with them. */
edges?: ReadonlyArray<readonly [string, string]>
/** Design-space bounding box of the block layout. Defaults with them. */
canvas?: { width: number; height: number }
}
/**
* 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) 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.
*
* The staged flow is injectable (`blocks`/`edges`/`canvas`), defaulting to the
* homepage's lead-enrichment flow - the enterprise loop stages its own flow
* through the same component.
*/
export function HeroWorkflowStage({
builtCount,
blocks = STAGE_BLOCKS,
edges = STAGE_EDGES,
canvas = STAGE_CANVAS,
}: HeroWorkflowStageProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(MAX_STAGE_SCALE)
const blocksById = useMemo(() => new Map(blocks.map((b) => [b.id, b])), [blocks])
// 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
// size (offsetWidth/Height) - the stage lives inside the platform loop's
// scale-transformed design-space layer, and getBoundingClientRect's visual
// size would compound that outer scale into a double shrink.
useLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const measure = () => {
const w = el.offsetWidth
const h = el.offsetHeight
if (w < 40 || h < 40) return
setScale(
Math.min(
MAX_STAGE_SCALE,
(w - STAGE_MARGIN) / canvas.width,
(h - STAGE_MARGIN) / canvas.height
)
)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [canvas.width, canvas.height])
const builtIds = useMemo(
() => new Set(blocks.slice(0, builtCount).map((b) => b.id)),
[blocks, builtCount]
)
return (
<div
ref={containerRef}
className='flex h-full w-full items-center justify-center overflow-hidden'
>
<div
className='relative shrink-0'
style={{
width: canvas.width * scale,
height: canvas.height * scale,
}}
>
<div
className='absolute top-0 left-0'
style={{
width: canvas.width,
height: canvas.height,
transform: `scale(${scale})`,
transformOrigin: '0 0',
}}
>
<svg
className='pointer-events-none absolute inset-0 overflow-visible'
width={canvas.width}
height={canvas.height}
viewBox={`0 0 ${canvas.width} ${canvas.height}`}
fill='none'
aria-hidden='true'
>
{edges.map(([from, to]) => {
const source = blocksById.get(from)
const target = blocksById.get(to)
if (!source || !target) return null
const visible = builtIds.has(from) && builtIds.has(to)
const s = handleAnchors(source).out
const t = handleAnchors(target).in
return (
<path
key={`${from}-${to}`}
d={verticalSmoothStep(s.x, s.y, t.x, t.y)}
pathLength={1}
stroke='var(--workflow-edge)'
strokeWidth={2}
strokeLinecap='round'
className='transition-[stroke-dashoffset] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] [stroke-dasharray:1]'
style={{ strokeDashoffset: visible ? 0 : 1 } as CSSProperties}
/>
)
})}
</svg>
{blocks.map((block) => {
const built = builtIds.has(block.id)
return (
<div
key={block.id}
className={cn(
'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: block.x, top: block.y, width: BLOCK_WIDTH }}
>
<StageBlockCard block={block} />
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { HeroPlatformLoop } from './hero-platform-loop'
@@ -0,0 +1,89 @@
'use client'
import { Tooltip } from '@sim/emcn'
/**
* Cursor-to-bubble gap for tooltips over the mini platform UI - tighter than
* the product-standard 16px so the bubble hugs the cursor proportionately to
* the scaled-down preview.
*/
export const HERO_TOOLTIP_OFFSET = 8
/** Percent-of-image bounds shared by both hotspot kinds. */
interface HotspotBounds {
left: string
top: string
width: string
height: string
}
/** An icon control that shows a tooltip on hover, like the real product. */
interface TooltipHotspot extends HotspotBounds {
label: string
}
/** A sidebar nav row that shows the real row-hover highlight on hover. */
interface RowHotspot extends HotspotBounds {
name: string
}
/**
* Icon controls measured from the capture (2560x1470): the collapse-sidebar
* toggle (x428-468, y36-72) and the Workflows section's "More actions"
* ellipsis (x376-412) and "Create workflow" plus (x424-460) at y920-952.
* Copy matches the real product's tooltips.
*/
const TOOLTIP_HOTSPOTS: TooltipHotspot[] = [
{ label: 'Collapse sidebar', left: '16.72%', top: '2.45%', width: '1.56%', height: '2.45%' },
{ label: 'More actions', left: '14.69%', top: '62.59%', width: '1.4%', height: '2.18%' },
{ label: 'Create workflow', left: '16.56%', top: '62.59%', width: '1.4%', height: '2.18%' },
]
/**
* Workspace nav rows (Tables / Files / Knowledge base), boxed like the real
* sidebar row (`h-[30px] rounded-lg`, text rows measured at y598-616, 662-680,
* 726-750): a 60px-tall (at 2x) highlight box centered on each row's text,
* starting left of the row ICON (icons measured at x37-39, the box carries the
* real row's 8px icon gutter) so the icon sits inside the highlight.
*/
const ROW_HOTSPOTS: RowHotspot[] = [
{ name: 'Tables', left: '0.82%', top: '39.25%', width: '17.15%', height: '4.08%' },
{ name: 'Files', left: '0.82%', top: '43.61%', width: '17.15%', height: '4.08%' },
{ name: 'Knowledge base', left: '0.82%', top: '48.16%', width: '17.15%', height: '4.08%' },
]
/**
* Hover layers over the BAKED sidebar pixels. Icon controls (collapse toggle,
* Workflows more/create) get transparent targets wired to the emcn
* {@link Tooltip} with the product's real copy ({@link HERO_TOOLTIP_OFFSET}
* keeps the bubble close over the mini UI). The Workspace nav rows instead
* reproduce the REAL sidebar row-hover state - `--surface-active` on the
* row's rounded box - via `mix-blend-multiply`, which paints over the baked
* white/text pixels to exactly the result of a background behind the text.
*/
export function SidebarHotspots() {
return (
<div aria-hidden='true' className='pointer-events-none absolute inset-0'>
{TOOLTIP_HOTSPOTS.map((spot) => (
<Tooltip.Root key={spot.label}>
<Tooltip.Trigger asChild>
<span
aria-label={spot.label}
className='pointer-events-auto absolute block'
style={{ left: spot.left, top: spot.top, width: spot.width, height: spot.height }}
/>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>{spot.label}</Tooltip.Content>
</Tooltip.Root>
))}
{ROW_HOTSPOTS.map((row) => (
<span
key={row.name}
aria-label={row.name}
className='pointer-events-auto absolute block cursor-pointer rounded-[6px] mix-blend-multiply transition-colors duration-100 hover:bg-[var(--surface-active)]'
style={{ left: row.left, top: row.top, width: row.width, height: row.height }}
/>
))}
</div>
)
}
@@ -0,0 +1,32 @@
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
interface StageBlockCardProps {
block: BlockDef
}
/**
* Block card shell for the vertical-flow stage - the hero-visual's faithful
* {@link WorkflowBlockContent} body with top (incoming) / bottom (outgoing)
* handle nubs instead of the horizontal-flow left/right ones. Shared by the
* hero's live workflow stage and the Build feature card's workflow peek.
*/
export function StageBlockCard({ block }: StageBlockCardProps) {
return (
<div className='relative rounded-[13px] border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm'>
<WorkflowBlockContent block={block} />
{!block.isTrigger && (
<span
aria-hidden
className='-translate-x-1/2 absolute top-[-7px] left-1/2 h-[7px] w-5 rounded-t-[2px] bg-[var(--workflow-edge)]'
/>
)}
{!block.isTerminal && (
<span
aria-hidden
className='-translate-x-1/2 absolute bottom-[-7px] left-1/2 h-[7px] w-5 rounded-b-[2px] bg-[var(--workflow-edge)]'
/>
)}
</div>
)
}
@@ -0,0 +1,129 @@
import { AgentIcon, CodeIcon, SlackIcon, StartIcon, TableIcon } from '@/components/icons'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
import { BLOCK_WIDTH } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/**
* Design-space geometry for the hero's live workflow stage - the lead-enrichment
* flow the chat conversation "builds": Start feeds the enrichment agent, a
* scoring function follows, and the flow fans out to Slack and Tables. Block
* tiles use the platform's grey text ramp (each block a different shade, dark
* enough to carry the white glyph) - color is reserved for REAL third-party
* marks, so only Slack keeps its brand tile (#611F69).
*
* Blocks are ordered by build sequence - the stage reveals `blocks[0..built-1]`
* as the loop's build counter advances, and an edge draws once both its
* endpoints are on canvas.
*/
export const STAGE_BLOCKS: BlockDef[] = [
{
id: 'start',
name: 'Start',
icon: StartIcon,
bgColor: 'var(--text-muted)',
isTrigger: true,
rows: [{ title: 'Inputs', value: '-' }],
x: 155,
y: 12,
},
{
id: 'enrich',
name: 'Enrich lead',
icon: AgentIcon,
bgColor: 'var(--text-primary)',
rows: [
{ title: 'Messages', value: '-' },
{ title: 'Model', value: '-' },
{ title: 'Files', value: '-' },
],
x: 155,
y: 172,
},
{
id: 'score',
name: 'Score fit',
icon: CodeIcon,
bgColor: 'var(--text-secondary)',
rows: [
{ title: 'Code', value: '-' },
{ title: 'Timeout', value: '-' },
],
x: 155,
y: 390,
},
{
id: 'slack',
name: 'Post to #sales',
icon: SlackIcon,
bgColor: '#611F69',
isTerminal: true,
rows: [
{ title: 'Channel', value: '-' },
{ title: 'Message', value: '-' },
],
x: 0,
y: 580,
},
{
id: 'tables',
name: 'Save to Tables',
icon: TableIcon,
bgColor: 'var(--text-body)',
isTerminal: true,
rows: [
{ title: 'Table', value: '-' },
{ title: 'Operation', value: '-' },
],
x: 310,
y: 580,
},
]
/** Source → target pairs, drawn in order as their endpoints land on canvas. */
export const STAGE_EDGES: ReadonlyArray<readonly [string, string]> = [
['start', 'enrich'],
['enrich', 'score'],
['score', 'slack'],
['score', 'tables'],
]
/** Design-space bounding box of the layout above. */
export const STAGE_CANVAS = { width: 560, height: 700 } as const
/**
* Approximate rendered block height - the icon-tile header (~40px) plus the
* rows section (16px padding + 21px per row + 8px gaps). Used to place a
* block's bottom (outgoing) handle; a few px of drift is invisible at stage
* scale.
*/
export function blockHeight(block: BlockDef): number {
const n = block.rows.length
return 40 + (n > 0 ? 16 + n * 21 + (n - 1) * 8 : 0)
}
/**
* Rounded orthogonal ("smoothstep") path for a VERTICAL flow - from a source's
* bottom-center handle to a target's top-center handle, stepping at the
* vertical midpoint with `r`-radius corners. The horizontal-flow counterpart
* lives in `hero-visual/workflow-data.ts`.
*/
export function verticalSmoothStep(sx: number, sy: number, tx: number, ty: number, r = 8): string {
if (Math.abs(tx - sx) < 1) return `M ${sx} ${sy} L ${tx} ${ty}`
const midY = (sy + ty) / 2
const dir = tx >= sx ? 1 : -1
return [
`M ${sx} ${sy}`,
`L ${sx} ${midY - r}`,
`Q ${sx} ${midY} ${sx + dir * r} ${midY}`,
`L ${tx - dir * r} ${midY}`,
`Q ${tx} ${midY} ${tx} ${midY + r}`,
`L ${tx} ${ty}`,
].join(' ')
}
/** Handle anchor points for a block at its fixed position. */
export function handleAnchors(block: BlockDef) {
return {
out: { x: block.x + BLOCK_WIDTH / 2, y: block.y + blockHeight(block) },
in: { x: block.x + BLOCK_WIDTH / 2, y: block.y },
}
}
@@ -0,0 +1,69 @@
/* Plain fade-in for the hero stat number - one smooth opacity ramp. */
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.fadeIn {
animation: fade-in 900ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/*
* Per-character entrance for the placeholder zeros: the leading "0" fades in
* first, then each following character staggers in left to right (70ms per
* character; the 11th lands at 700ms + 500ms fade = fully in by ~1.2s).
*/
.char {
animation: fade-in 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.char:nth-child(2) {
animation-delay: 70ms;
}
.char:nth-child(3) {
animation-delay: 140ms;
}
.char:nth-child(4) {
animation-delay: 210ms;
}
.char:nth-child(5) {
animation-delay: 280ms;
}
.char:nth-child(6) {
animation-delay: 350ms;
}
.char:nth-child(7) {
animation-delay: 420ms;
}
.char:nth-child(8) {
animation-delay: 490ms;
}
.char:nth-child(9) {
animation-delay: 560ms;
}
.char:nth-child(10) {
animation-delay: 630ms;
}
.char:nth-child(11) {
animation-delay: 700ms;
}
@media (prefers-reduced-motion: reduce) {
.fadeIn,
.char {
animation: none !important;
}
}
@@ -0,0 +1,166 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '@sim/emcn'
import styles from '@/app/(landing)/components/hero/components/hero-stat/hero-stat.module.css'
const STAT_VALUE = '0.00000206%'
/** The last-three-digit count-up range: 000 -> 206 (Stripe-style iterate). */
const COUNT_FROM = 0
const COUNT_TO = 206
/** Duration of the count-up once it starts. */
const COUNT_DUR_MS = 1100
/** The LIGHT-GREY placeholder zeros the number staggers in as, char by char. */
const START_CHARS = `0.00000${String(COUNT_FROM).padStart(3, '0')}%`.split('')
/**
* When the per-character stagger has fully landed (last char starts at 700ms
* + its 500ms fade - see hero-stat.module.css).
*/
const CHARS_IN_AT_MS = 1200
/**
* The settle beat starts once the character stagger completes, plus a short
* hold so the number reads before it rises.
*/
const SETTLE_AT_MS = CHARS_IN_AT_MS + 150
/**
* The label reveal waits until the rising number (500ms ease-out) has cleared
* the label's row, so the two never overlap mid-flight.
*/
const REVEAL_AT_MS = SETTLE_AT_MS + 400
/** The dark fill grows in last, once the label's fade-up has finished. */
const FILL_AT_MS = REVEAL_AT_MS + 350
/**
* The hero's right-side stat, with a staggered page-load entrance in four
* beats: the number appears at the bottom of the stat as LIGHT-GREY
* placeholder zeros - the leading "0" fades in first, then each following
* character staggers in left to right - alongside the progress rail (short,
* bottom-anchored, spanning exactly the number's 18px line), the number rises
* into its final slot while the rail GROWS upward at the same rate (same
* duration + easing, so its top edge tracks the number's top), then the
* "Global work done by Sim" label fades up beneath it while the number
* DARKENS to `--text-primary` and the last digits COUNT UP 000 -> 206
* (Stripe-style, ease-out so the ticks slow into the final value), and
* finally the dark progress fill grows up from the rail's foot.
*
* Under `prefers-reduced-motion` everything renders settled immediately (the
* fades are also disabled in the CSS module).
*/
export function HeroStat() {
const [settled, setSettled] = useState(false)
const [revealed, setRevealed] = useState(false)
const [filled, setFilled] = useState(false)
const [count, setCount] = useState(COUNT_FROM)
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
let raf = 0
let settleTimer: ReturnType<typeof setTimeout>
let revealTimer: ReturnType<typeof setTimeout>
let fillTimer: ReturnType<typeof setTimeout>
let countTimer: ReturnType<typeof setTimeout>
const clearScheduled = () => {
clearTimeout(settleTimer)
clearTimeout(revealTimer)
clearTimeout(fillTimer)
clearTimeout(countTimer)
cancelAnimationFrame(raf)
}
const showSettled = () => {
clearScheduled()
setSettled(true)
setRevealed(true)
setFilled(true)
setCount(COUNT_TO)
}
const runEntrance = () => {
setSettled(false)
setRevealed(false)
setFilled(false)
setCount(COUNT_FROM)
settleTimer = setTimeout(() => setSettled(true), SETTLE_AT_MS)
revealTimer = setTimeout(() => setRevealed(true), REVEAL_AT_MS)
fillTimer = setTimeout(() => setFilled(true), FILL_AT_MS)
countTimer = setTimeout(() => {
const start = performance.now()
const tick = (now: number) => {
const t = Math.min((now - start) / COUNT_DUR_MS, 1)
const eased = 1 - (1 - t) ** 3
setCount(Math.round(COUNT_FROM + (COUNT_TO - COUNT_FROM) * eased))
if (t < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
}, REVEAL_AT_MS)
}
// Re-synced on 'change' - not just on mount - so toggling the preference
// mid-entrance snaps straight to the settled state instead of leaving the
// stagger/count-up to keep running on its own clock.
const syncMotionPreference = () => {
clearScheduled()
if (media.matches) {
showSettled()
return
}
runEntrance()
}
syncMotionPreference()
media.addEventListener('change', syncMotionPreference)
return () => {
media.removeEventListener('change', syncMotionPreference)
clearScheduled()
}
}, [])
return (
<div className='flex w-fit shrink-0 items-stretch gap-3 max-lg:hidden'>
<div aria-hidden='true' className={cn('relative w-[2px]', styles.fadeIn)}>
<div
className={cn(
'absolute inset-x-0 bottom-0 flex flex-col justify-end rounded-full bg-[var(--surface-6)] transition-[top] duration-500',
settled ? 'top-0' : 'top-[calc(100%-18px)]'
)}
>
<div
className={cn(
'w-full rounded-full bg-[var(--text-body)] transition-[height] duration-300',
filled ? 'h-[4px]' : 'h-0'
)}
/>
</div>
</div>
<div className='flex flex-col gap-1.5'>
<p
className={cn(
'text-[18px] tabular-nums leading-none transition-[transform,color] duration-500',
settled ? 'translate-y-0' : 'translate-y-[20px]',
revealed ? 'text-[var(--text-primary)]' : 'text-[var(--surface-7)]'
)}
>
<span className='sr-only'>{STAT_VALUE}</span>
<span aria-hidden='true'>
{revealed
? `0.00000${String(count).padStart(3, '0')}%`
: START_CHARS.map((ch, i) => (
<span key={i} className={styles.char}>
{ch}
</span>
))}
</span>
</p>
<p
className={cn(
'text-[var(--text-muted)] text-sm leading-none transition-[opacity,transform] duration-300',
revealed ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'
)}
>
Global work done by Sim
</p>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { HeroStat } from './hero-stat'
@@ -0,0 +1,40 @@
import { cn } from '@sim/emcn'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
interface BlockHandlesProps {
block: BlockDef
handlesVisible?: boolean
}
/**
* The decorative edge-handle nubs for a block - an inbound nub on the left
* unless the block is a trigger, an outbound nub on the right unless it's
* terminal. Absolutely positioned, so the caller must be a `relative` (or
* otherwise positioned) box of the block's width. Shared so the morphed chat
* card (GitHub, rendered as content-only) can carry the same handles as the
* real {@link WorkflowBlock} satellites.
*/
export function BlockHandles({ block, handlesVisible = true }: BlockHandlesProps) {
return (
<>
{!block.isTrigger && (
<span
aria-hidden
className={cn(
'-translate-y-1/2 absolute top-5 left-[-7px] h-5 w-[7px] rounded-l-[2px] bg-[var(--workflow-edge)] transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
handlesVisible ? 'opacity-100' : 'opacity-0'
)}
/>
)}
{!block.isTerminal && (
<span
aria-hidden
className={cn(
'-translate-y-1/2 absolute top-5 right-[-7px] h-5 w-[7px] rounded-r-[2px] bg-[var(--workflow-edge)] transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
handlesVisible ? 'opacity-100' : 'opacity-0'
)}
/>
)}
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,413 @@
'use client'
import {
type CSSProperties,
type RefObject,
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { cn } from '@sim/emcn'
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
import {
ANSWER_TEXT,
BLOCK_WIDTH,
BLOCKS,
HOME_GREETING,
PROMPT_ATOMS,
type PromptAtom,
SEND_BUBBLE_ENTER_MS,
SEND_BUBBLE_REVEAL_DELAY_MS,
WORKFLOW_FOCUS_SCALE,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/**
* What the Mothership chat is showing right now. The travelling thinking loader
* itself lives at the hero root (so it can outlive these layers and track its
* target through the camera zoom + pan); this stage only lays out the card and
* exposes the send button and reply slot as the loader's ref targets.
*
* - `compose` - greeting + prompt input (the typewriter).
* - `morphing` - the instant after send: the disc morphs into the loader while
* the typed prompt fades. The card holds its compose height (the input stays in
* flow, just fading) so it never reshapes mid-morph - the disc just becomes the
* loader in place.
* - `sending` - the morph is done; the prompt is now a user bubble that animates
* in above the loader, growing the card, while the camera holds zoomed in.
* - `thinking` - conversation layout (user bubble + an empty reply slot whose
* height the loader will occupy), shown as the loader slides/docks there.
* - `answering` - the typed reply fills the reply slot.
* - `block` - the card morphs into the first workflow block (the chat shell
* resizes and its content becomes the block's), handing off to the workflow.
*/
export type HomeMode = 'compose' | 'morphing' | 'sending' | 'thinking' | 'answering' | 'block'
/** The first workflow block, shown inside the card during the morph. */
const FIRST_BLOCK = BLOCKS[0]
/** GitHub block content's natural (unscaled) height in px. */
const GH_CONTENT_HEIGHT = 77
/** The chat card's width (compose/conversation). */
const CHAT_CARD_WIDTH = 460
/**
* Natural height of the compose card on first paint - `py-2` (16) + the input
* row (32) + `gap-2` (8) + the action row (28). Seeds the card so it renders at
* its true height immediately and never transitions/resizes on load; the
* observer keeps it exact thereafter. Must track the content's padding/spacing.
*/
const COMPOSE_CARD_HEIGHT = 84
/**
* Card width/height once morphed into the first block (block content × focus
* scale). Deliberately smaller than the chat card so the morph reads as a
* visible SHRINK + reshape, not a same-size content crossfade.
*/
const BLOCK_CARD_WIDTH = BLOCK_WIDTH * WORKFLOW_FOCUS_SCALE
const BLOCK_CARD_HEIGHT = GH_CONTENT_HEIGHT * WORKFLOW_FOCUS_SCALE
interface StageHomeProps {
/** Which beat of the chat to render. */
mode: HomeMode
/** Prompt atoms the typewriter has revealed (compose mode). */
typedCount: number
/** Characters of the Mothership's reply revealed so far (answering mode). */
answerTypedCount: number
/** The prompt text region - the root cursor targets this for its "click in". */
inputRef: RefObject<HTMLDivElement | null>
/** The send button - the root cursor + travelling loader target this to "send". */
sendRef: RefObject<HTMLDivElement | null>
/** The dock - an invisible spot at the LEFT of the send-button row that the
* loader slides to (same row, so the slide is purely horizontal and the card
* never has to resize for it). */
dockRef: RefObject<HTMLDivElement | null>
/**
* The white card element. The parent measures it and, during the send beat
* (`sending`), drives its height per-frame via the `--hero-card-h` CSS variable
* - in lockstep with the camera pan - so the grow-to-fit-the-bubble can't shake
* the card the way a CSS height transition fighting the rAF loop would.
*/
cardRef: RefObject<HTMLDivElement | null>
/**
* Reveal the greeting headline. Held back until the input is being composed,
* then fades in on the `hero-stage-fade` keyframe, slowed to 1.2s (≈3× the
* scene's stock 420ms) so the headline eases in gently. Its space is always
* reserved, so revealing it never shifts the layout.
*/
showGreeting?: boolean
/** Press the send button (scale it down) - driven by the click beat. */
pressed?: boolean
}
/** Staggered enter for a chat bubble - translateY + opacity + blur, interruptible. */
const ENTER_BASE = 'transition-[opacity,transform,filter] ease-[cubic-bezier(0.2,0,0,1)]'
const enterState = (shown: boolean) =>
shown ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-1.5 opacity-0 blur-[3px]'
const Caret = () => (
<span
className='ml-px inline-block h-[16px] w-px translate-y-[2px] animate-caret-blink bg-[var(--text-primary)]'
aria-hidden='true'
/>
)
/** Renders prompt atoms (plain chars + inline `@mention` icon-chips). */
function PromptAtoms({ atoms }: { atoms: PromptAtom[] }) {
return (
<>
{atoms.map((atom, i) =>
atom.kind === 'char' ? (
<span key={`${i}-${atom.char}`}>{atom.char}</span>
) : (
<span key={`${i}-${atom.label}`}>
<span className='relative'>
<span className='invisible'>@</span>
<atom.icon className='absolute inset-0 m-auto size-[12px] translate-y-[1.25px] text-[var(--text-icon)]' />
</span>
{atom.label}
</span>
)
)}
</>
)
}
/**
* The Mothership home stage - a single white card that morphs adaptively.
*
* Both content layers (the compose input and the conversation) are absolutely
* stacked and TOP-anchored; the card measures whichever is active and animates
* its own height to hug it (`transition-[height]`). This makes the card flexible
* - it grows from the input to the reply, and from the thinking loader to the
* typed answer - with no dead space and no fixed min-height. Top-anchoring keeps
* the user bubble pinned, so the loader's label crossfade never shoves it; the
* card `overflow-hidden`s any transient. Cursor is owned by the parent; this
* stage only exposes the input and send button as ref targets. Decorative.
*/
export function StageHome({
mode,
typedCount,
answerTypedCount,
inputRef,
sendRef,
dockRef,
cardRef,
showGreeting = false,
pressed = false,
}: StageHomeProps) {
const isCompose = mode === 'compose'
const isMorphing = mode === 'morphing'
const isBlock = mode === 'block'
const isAnswering = mode === 'answering'
// The prompt input holds the card's compose height through the disc→loader morph
// (`compose`/`morphing`), then fades to a right-aligned bubble once send lands.
const inputInFlow = isCompose || isMorphing
// The typed prompt is a right-aligned chat bubble from the moment send is hit
// (`sending`) onward.
const showBubble = !isCompose && !isMorphing && !isBlock
// While the bubble first appears (`sending`), the PARENT drives the card height
// per-frame (via `--hero-card-h`) in lockstep with the camera, so the grow can't
// shake. Every other beat lets the card own + CSS-transition its own height.
const growControlled = mode === 'sending'
// Hold the card height steady - no CSS height-transition - through both the
// parent-driven grow (`sending`) AND the loader slide + camera track
// (`thinking`). The slide pans the camera by measuring the dock's live
// position each frame and assumes the card holds its size; transitioning
// height there makes the card resize under that measurement, so it jitters.
const holdCardHeight = growControlled || mode === 'thinking'
const contentRef = useRef<HTMLDivElement>(null)
// Seeded at the natural compose height so the send button is never clipped on
// the very first paint; the observer below keeps it exact from then on.
const [cardHeight, setCardHeight] = useState(COMPOSE_CARD_HEIGHT)
// Hug the chat column: measure its natural height and animate the card to it.
// Guard against a collapsed/unpainted layout (width ~0) measuring a
// wildly-wrapped height and poisoning the card size.
const measure = useCallback(() => {
const el = contentRef.current
if (el && el.offsetWidth > 120) setCardHeight(el.offsetHeight)
}, [])
// Synchronous, pre-paint, on every beat that reshapes the content (prompt typing
// out, input→bubble swap, reply filling in) - so the card grows in lockstep with
// no lag. In block mode the card takes a fixed height (`isBlock` branch on the
// style), so the measured value is simply ignored there.
useLayoutEffect(() => {
measure()
}, [measure, mode, typedCount, answerTypedCount])
// Robust backstop: also re-fit on first-paint layout, font load, or any other
// reflow the beat-driven pass can't see - so the send button is never clipped.
useLayoutEffect(() => {
const el = contentRef.current
if (!el) return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [measure])
const visible = PROMPT_ATOMS.slice(0, typedCount)
const isEmpty = typedCount === 0
const answer = ANSWER_TEXT.slice(0, answerTypedCount)
const bubbleTransitionStyle = {
transitionDelay: mode === 'sending' ? `${SEND_BUBBLE_REVEAL_DELAY_MS}ms` : '0ms',
transitionDuration: `${SEND_BUBBLE_ENTER_MS}ms`,
} satisfies CSSProperties
return (
<div className='flex h-full w-full flex-col items-center justify-center px-10'>
<div className='flex w-full max-w-[460px] flex-col gap-5'>
{/* Greeting - visible while composing, then fades. Its SPACE is held
through the sending/thinking scene so collapsing it never re-centres
the column and shifts the card the camera is locked onto; it only
truly collapses once the reply takes over (answering) or the card
morphs to a block. */}
<div
className={cn(
'overflow-hidden text-center transition-[height,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]',
mode === 'compose'
? 'h-[40px] opacity-100'
: mode === 'sending' || mode === 'thinking'
? 'h-[40px] opacity-0'
: 'h-0 opacity-0'
)}
>
{showGreeting ? (
<p className='text-[30px] text-[var(--text-primary)] leading-[40px] [animation:hero-stage-fade_1200ms_cubic-bezier(0.23,1,0.32,1)_both]'>
{HOME_GREETING}
</p>
) : null}
</div>
{/* White card - the SAME shell throughout: it hugs the active chat content,
then resizes and rounds down to become the first workflow block (the
chat → workflow morph happens on this one continuous element). */}
<div
ref={cardRef}
className={cn(
// The chat input's aesthetic (rounded-2xl, border, soft shadow) is
// kept THROUGHOUT - including once morphed into the workflow block -
// so it stays the same white card, just resized.
'relative mx-auto overflow-hidden rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm',
// While the height is held (the parent-driven send-bubble grow, and the
// loader slide where the card keeps its size), do NOT CSS-transition
// height - a transition would fight the per-frame camera/dock tracking
// and read as jitter. Every other beat eases width/height/transform with
// the shared curve.
holdCardHeight
? 'transition-[width,transform] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)]'
: 'transition-[width,height,transform] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
// Nudge up to cancel the chat column's greeting+gap offset, so the
// card centers exactly where the focused workflow block lands.
isBlock && '-translate-y-[10px]'
)}
style={{
width: isBlock ? BLOCK_CARD_WIDTH : CHAT_CARD_WIDTH,
// During the grow, follow the parent-driven variable (seeded to the
// compose height so there's never a collapsed first frame).
height: isBlock
? BLOCK_CARD_HEIGHT
: growControlled
? `var(--hero-card-h, ${COMPOSE_CARD_HEIGHT}px)`
: cardHeight,
}}
>
{/* Chat content - ONE column: the user message (the typewriter input,
then the sent bubble) over an action row (the send button + the
loader's dock, then the reply). The column stays anchored to the
BOTTOM through the send-bubble grow AND the loader slide (the whole
`holdCardHeight` window): during the grow the card expands UPWARD to
reveal the bubble, and through the slide the bottom edge never moves -
so the dock/send button the camera tracks can't shift as the height
source switches. Re-anchoring there would nudge them a sub-pixel and
the slide's camera would chase it (a visible jitter). Every other beat
is top-anchored (content fills the card, so it reads the same either
way), which keeps the block-morph and the reply grow behaving as before. */}
<div
ref={contentRef}
className={cn(
'absolute inset-x-0 flex flex-col gap-2 px-2.5 py-2 transition-opacity duration-[220ms] ease-[cubic-bezier(0.23,1,0.32,1)]',
holdCardHeight ? 'bottom-0' : 'top-0',
isBlock && 'pointer-events-none opacity-0'
)}
>
{/* Message: the typewriter input while composing + morphing, then it
fades and a right-aligned user bubble animates in once `sending`
begins. The input stays in flow (holding the compose height) right
through the morph, so the card never reshapes as the disc becomes
the loader; the bubble takes over the flow only after, growing the
card in one clean transition. */}
<div className='relative'>
<div
ref={inputRef}
className={cn(
'min-h-[24px] px-1 py-1 font-body text-[15px] text-[var(--text-primary)] leading-[24px] tracking-[-0.015em] transition-opacity duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
isCompose ? 'opacity-100' : 'opacity-0',
!inputInFlow && 'pointer-events-none absolute inset-x-0 top-0'
)}
>
{isEmpty ? (
<span className='font-[380] text-[var(--text-subtle)]'>
Ask Sim to build an agent
</span>
) : (
<>
<PromptAtoms atoms={visible} />
<Caret />
</>
)}
</div>
<div
style={bubbleTransitionStyle}
className={cn(
'ml-auto w-fit max-w-[82%] rounded-2xl bg-[var(--surface-5)] px-3.5 py-2 font-body text-[15px] text-[var(--text-primary)] leading-[22px] tracking-[-0.015em]',
ENTER_BASE,
showBubble
? enterState(true)
: cn('pointer-events-none absolute top-0 right-0', enterState(false))
)}
>
<PromptAtoms atoms={PROMPT_ATOMS} />
</div>
</div>
{/* Action row: the loader's dock (left) and the send button (right).
The dock is the loader's slide target; once answering, the reply
types out from that same left edge as the loader fades, so the
docked loader reads as the start of the reply. */}
<div className='flex items-start justify-between gap-2'>
<div className='min-w-0 flex-1'>
{isAnswering ? (
<p className='px-1 font-body text-[15px] text-[var(--text-primary)] leading-[22px] tracking-[-0.015em]'>
{answer}
<Caret />
</p>
) : (
<div ref={dockRef} aria-hidden='true' className='size-[28px]' />
)}
</div>
<div
ref={sendRef}
aria-hidden='true'
className={cn(
'flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-[opacity,transform,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]',
// Visible only while composing; once send is hit the root loader's
// settled orb takes its place (it stays laid out as the loader's
// measure + slide-from target).
isCompose ? 'opacity-100' : 'opacity-0',
// Subtle interaction: lighten on hover, dip in size on press -
// both for a real cursor and for the animation's click beat.
isCompose && 'hover:bg-[#484848] active:scale-90',
pressed && 'scale-90'
)}
>
{/* Up-arrow that draws itself on (shaft then head) via
stroke-dashoffset - `pathLength={1}` normalizes the dash so
one offset value covers the whole glyph. Retracts as send is
hit so the disc is clean when the loader takes it over. */}
<svg viewBox='0 0 24 24' fill='none' className='size-4' aria-hidden='true'>
<path
d='M12 19V5M5 12l7-7 7 7'
pathLength={1}
stroke='var(--surface-2)'
strokeWidth={2}
strokeLinecap='round'
strokeLinejoin='round'
className={cn(
'transition-[stroke-dashoffset] duration-[520ms] ease-[cubic-bezier(0.23,1,0.32,1)] [stroke-dasharray:1]',
isCompose ? '[stroke-dashoffset:0]' : '[stroke-dashoffset:1]'
)}
/>
</svg>
</div>
</div>
</div>
{/* Block layer: the card has morphed into the first workflow block.
Same content as the workflow stage's focused block, at the focus
scale, so handing off to the canvas is seamless. Top-anchored to
match the workflow block's origin. */}
<div
aria-hidden='true'
className={cn(
'absolute inset-x-0 top-0 transition-opacity duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]',
// Hold off until the chat content (≈220ms fade) is gone, then fade in.
isBlock ? 'opacity-100 [transition-delay:280ms]' : 'pointer-events-none opacity-0'
)}
>
<div
className='relative'
style={{
width: BLOCK_WIDTH,
transform: `scale(${WORKFLOW_FOCUS_SCALE})`,
transformOrigin: 'top left',
}}
>
<WorkflowBlockContent block={FIRST_BLOCK} />
<span
aria-hidden
className='-translate-y-1/2 absolute top-5 right-[-7px] h-5 w-[7px] rounded-r-[2px] bg-[var(--workflow-edge)]'
/>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,187 @@
import type { CSSProperties, RefObject } from 'react'
import { cn } from '@sim/emcn'
import { Upload, X } from 'lucide-react'
import {
GRAPH_EDGES,
GRAPH_NODES,
GRAPH_VIEWBOX,
KB_FILES,
KB_NAME,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/** Which beat of the knowledge-base flow the modal is showing. */
export type KbStage = 'empty' | 'files' | 'embeddings'
interface KnowledgeBasePanelProps {
stage: KbStage
/** The Create button - the root cursor targets this to "create". */
createRef: RefObject<HTMLSpanElement | null>
/**
* `modal` renders the standalone centered modal (its own chrome + entrance);
* `morph` renders the content only, filling its host, so a satellite block can
* morph into it in scene space.
*/
motion?: 'modal' | 'morph'
}
/**
* The knowledge-base create UI - a faithful, decorative replica of the real
* `ChipModal` create flow. First an empty dropzone ("Drop files here"); then
* files drop in from above as if dragged from Finder; then the document area
* becomes an embedding map that builds itself node by node while the footer
* reads "Creating…". The Create button is exposed as a cursor target.
*/
export function KnowledgeBasePanel({
stage,
createRef,
motion = 'modal',
}: KnowledgeBasePanelProps) {
const creating = stage === 'embeddings'
const content = (
<div
className={cn(
'overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]',
motion === 'morph' && 'h-full'
)}
>
<div className='flex items-center justify-between px-4 pt-3 pb-2.5'>
<span className='font-medium text-[15px] text-[var(--text-primary)]'>
Create Knowledge Base
</span>
<X className='size-4 text-[var(--text-muted)]' />
</div>
<div className='flex flex-col gap-4 px-4 pb-4'>
<div className='flex flex-col gap-[9px]'>
<span className='text-[13px] text-[var(--text-muted)]'>Name</span>
<div className='flex h-[30px] items-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 text-[14px] text-[var(--text-body)]'>
{KB_NAME}
</div>
</div>
<div className='flex flex-col gap-[9px]'>
<span className='text-[13px] text-[var(--text-muted)]'>
{creating ? 'Embeddings' : 'Upload Documents'}
</span>
<div className='relative h-[188px]'>
{stage === 'empty' && (
<div className='absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-lg border border-[var(--border-1)] border-dashed bg-[var(--surface-5)] text-center'>
<Upload className='size-5 text-[var(--text-muted)]' />
<span className='text-[var(--text-primary)] text-caption'>Drop files here</span>
<span className='text-[var(--text-tertiary)] text-xs'>PDF, DOCX, TXT, CSV, MD</span>
</div>
)}
{stage === 'files' && (
<div className='absolute inset-0 flex flex-col justify-center gap-2'>
{KB_FILES.map((file, i) => (
<div
key={file.name}
className={cn(
'flex items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] p-2',
'animate-hero-file-drop opacity-0 [animation-delay:var(--drop-delay)] motion-reduce:animate-none motion-reduce:opacity-100'
)}
style={{ '--drop-delay': `${120 + i * 170}ms` } as CSSProperties}
>
<file.icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-body)]'>
{file.name}
</span>
<span className='flex-shrink-0 text-[14px] text-[var(--text-muted)]'>
{file.size}
</span>
</div>
))}
</div>
)}
{creating && (
<div className='absolute inset-0 flex flex-col items-center justify-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-3'>
<svg
className='h-[140px] w-full'
viewBox={`0 0 ${GRAPH_VIEWBOX.width} ${GRAPH_VIEWBOX.height}`}
fill='none'
aria-hidden='true'
>
<title>embedding graph</title>
{GRAPH_EDGES.map(([a, b], i) => {
const from = GRAPH_NODES[a]
const to = GRAPH_NODES[b]
return (
<path
key={`${a}-${b}`}
d={`M ${from.x} ${from.y} L ${to.x} ${to.y}`}
pathLength={1}
className='animate-hero-edge-draw [animation-delay:var(--pop-delay)] [stroke-dasharray:1] [stroke-dashoffset:1] motion-reduce:animate-none motion-reduce:[stroke-dashoffset:0]'
stroke='var(--text-subtle)'
strokeWidth={0.5}
style={{ '--pop-delay': `${i * 45}ms` } as CSSProperties}
/>
)
})}
{GRAPH_NODES.map((node, i) => (
<circle
key={`${node.x}-${node.y}`}
cx={node.x}
cy={node.y}
r={node.hub ? 3.4 : i % 3 === 0 ? 2.4 : 1.9}
className={cn(
'opacity-0 [transform-box:fill-box] [transform-origin:center] motion-reduce:animate-none motion-reduce:opacity-100',
node.hub
? 'animate-[hero-node-pop_440ms_cubic-bezier(0.16,1,0.3,1)_var(--pop-delay)_forwards,hero-graph-pulse_2600ms_ease-in-out_calc(var(--pop-delay)+800ms)_infinite]'
: 'animate-hero-node-pop [animation-delay:var(--pop-delay)]'
)}
fill={
node.hub
? 'var(--text-primary)'
: i % 2 === 0
? 'var(--text-secondary)'
: 'var(--text-muted)'
}
style={{ '--pop-delay': `${300 + i * 40}ms` } as CSSProperties}
/>
))}
</svg>
<span className='text-[var(--text-muted)] text-caption'>
Generating embeddings
</span>
</div>
)}
</div>
</div>
</div>
<div className='flex items-center justify-end gap-2 rounded-b-lg bg-[var(--surface-3)] px-4 pt-2 pb-2'>
<span className='flex h-[30px] items-center rounded-lg px-2 text-[14px] text-[var(--text-body)]'>
Cancel
</span>
<span
ref={createRef}
className='flex h-[30px] items-center rounded-lg bg-[var(--text-primary)] px-2 text-[14px] text-[var(--text-inverse)]'
>
{creating ? 'Creating…' : 'Create'}
</span>
</div>
</div>
)
if (motion === 'morph') {
return (
<div className='h-full w-full animate-hero-kb-content-morph opacity-0 motion-reduce:animate-none motion-reduce:opacity-100'>
{content}
</div>
)
}
return (
<div
className={cn(
'w-full max-w-[420px] rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)]',
'animate-hero-modal-in motion-reduce:animate-none'
)}
>
{content}
</div>
)
}
@@ -0,0 +1,132 @@
'use client'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { WorkflowBlock } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block'
import {
BLOCK_WIDTH,
BLOCKS,
CANVAS,
EDGES,
WORKFLOW_FOCUS_SCALE,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/** The camera beat: held on the first block, panning out, or settled on the whole flow. */
export type WorkflowCameraStage = 'focus' | 'out' | 'hold'
interface StageWorkflowProps {
stage: WorkflowCameraStage
}
/** First (GitHub) block center in design space - the camera's focus target. */
const FOCUS_CENTER = { x: BLOCK_WIDTH / 2, y: 38 }
/** Whole-canvas center - the overview camera target. */
const CANVAS_CENTER = { x: CANVAS.width / 2, y: CANVAS.height / 2 }
/** Zoomed-in scale while held on the first block (≈ the morphed chat card size). */
const FOCUS_SCALE = WORKFLOW_FOCUS_SCALE
/** Pulled-back scale that fits the whole workflow in the panel. */
const OVERVIEW_SCALE = 0.68
/**
* The workflow stage of the hero visual - a design-space canvas with a moving
* "camera". It opens **focused** on the first block (the chat card has just
* morphed into it), holds while that block's content lands and the first edge
* draws, then the camera **pans + zooms out together** to reveal the whole
* GitHub → Agent → Jira flow (the {@link stage} prop drives this).
*
* The camera is a transform on the design-space canvas, positioned so the focus
* point lands at the panel center: `translate(vpW/2 - cx·s, vpH/2 - cy·s)
* scale(s)` (origin top-left). The panel size is measured; until it is known,
* and on first mount, the transition is suppressed so the opening focus frame
* doesn't animate in from a fallback. Purely decorative - `aria-hidden`.
*/
export function StageWorkflow({ stage }: StageWorkflowProps) {
const viewportRef = useRef<HTMLDivElement>(null)
const [vp, setVp] = useState<{ w: number; h: number } | null>(null)
const [animate, setAnimate] = useState(false)
useLayoutEffect(() => {
const el = viewportRef.current
if (!el) return
const measure = () => {
const r = el.getBoundingClientRect()
// Guard an unpainted/collapsed panel from poisoning the camera math.
if (r.width > 120 && r.height > 120) setVp({ w: r.width, h: r.height })
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
// Enable the camera transition only after the opening focus frame is painted,
// so mounting (and the first measurement) snaps into focus rather than gliding.
useEffect(() => {
if (vp) setAnimate(true)
}, [vp])
const focused = stage === 'focus'
const center = focused ? FOCUS_CENTER : CANVAS_CENTER
const scale = focused ? FOCUS_SCALE : OVERVIEW_SCALE
const transform = vp
? `translate(${vp.w / 2 - center.x * scale}px, ${vp.h / 2 - center.y * scale}px) scale(${scale})`
: `translate(0px, 0px) scale(${OVERVIEW_SCALE})`
return (
<div ref={viewportRef} className='relative h-full w-full overflow-hidden'>
<div
className={cn(
'absolute top-0 left-0',
animate && 'transition-transform duration-[1700ms] ease-[cubic-bezier(0.22,1,0.36,1)]'
)}
style={{
width: CANVAS.width,
height: CANVAS.height,
transform,
transformOrigin: '0 0',
}}
>
<svg
className='absolute inset-0'
width={CANVAS.width}
height={CANVAS.height}
viewBox={`0 0 ${CANVAS.width} ${CANVAS.height}`}
fill='none'
aria-hidden='true'
>
{EDGES.map((edge, i) => (
<path
key={edge.id}
d={edge.d}
pathLength={1}
stroke='var(--workflow-edge)'
strokeWidth={2}
strokeLinecap='round'
className='transition-[stroke-dashoffset] duration-[700ms] ease-[cubic-bezier(0.22,1,0.36,1)] [stroke-dasharray:1]'
// Edges stay undrawn while focused; once the camera pulls out they
// draw in order (the second trails the first) as each target is revealed.
style={
{
strokeDashoffset: focused ? 1 : 0,
transitionDelay: `${i * 700}ms`,
} as CSSProperties
}
/>
))}
</svg>
{BLOCKS.map((block) => (
// The first block is already on screen - the chat card morphed into it,
// and the focused camera lands it pixel-matched here; the rest sit in
// design space and are revealed by the camera pull-out.
<div
key={block.id}
className='absolute'
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
>
<WorkflowBlock block={block} />
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,58 @@
import { cn } from '@sim/emcn'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
interface WorkflowBlockContentProps {
block: BlockDef
}
/**
* The inner content of a workflow block - the icon-tile header and optional
* label → value rows - WITHOUT the card chrome or handle nubs. Split out so the
* chat card can host the exact same content while morphing into the first block
* (the card keeps its own continuous shell; only this content crossfades in).
*/
export function WorkflowBlockContent({ block }: WorkflowBlockContentProps) {
return (
<>
<div
className={cn(
'flex items-center gap-2.5 p-2',
block.rows.length > 0 && 'border-[var(--border-1)] border-b'
)}
>
<div
className={cn(
'flex size-[24px] flex-shrink-0 items-center justify-center rounded-md',
block.tileBorder && 'border border-[var(--border-1)]'
)}
style={{ background: block.bgColor }}
>
{block.tileBorder ? (
<block.icon className='size-[16px]' />
) : (
<block.icon className='size-[16px] text-white' />
)}
</div>
<span className='truncate font-medium text-[16px] text-[var(--text-body)]'>
{block.name}
</span>
</div>
{block.rows.length > 0 && (
<div className='flex flex-col gap-2 p-2'>
{block.rows.map((row) => (
<div key={row.title} className='flex items-center gap-2'>
<span className='flex-shrink-0 text-[14px] text-[var(--text-muted)]'>
{row.title}
</span>
<span className='flex min-w-0 flex-1 items-center justify-end gap-1.5 text-[14px] text-[var(--text-body)]'>
{row.valueIcon && <row.valueIcon className='size-[14px] flex-shrink-0' />}
<span className='truncate'>{row.value}</span>
</span>
</div>
))}
</div>
)}
</>
)
}
@@ -0,0 +1,38 @@
import { cn } from '@sim/emcn'
import { BlockHandles } from '@/app/(landing)/components/hero/components/hero-visual/block-handles'
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
interface WorkflowBlockProps {
block: BlockDef
contentVisible?: boolean
handlesVisible?: boolean
}
/**
* A pure presentational workflow block card, faithful to the real WorkflowBlock:
* a fixed-width card with an icon-tile header and optional label → value rows,
* plus decorative handle nubs on its left and right edges. Stateless and
* client-free - positioning and the rise animation are owned by the parent stage.
* `contentVisible`/`handlesVisible` crossfade the content and handles so a block
* can soften into a shell (used when the Jira block morphs into the KB panel).
*/
export function WorkflowBlock({
block,
contentVisible = true,
handlesVisible = true,
}: WorkflowBlockProps) {
return (
<div className='relative w-[250px] rounded-[13px] border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm'>
<div
className={cn(
'transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
contentVisible ? 'opacity-100' : 'opacity-0'
)}
>
<WorkflowBlockContent block={block} />
</div>
<BlockHandles block={block} handlesVisible={handlesVisible} />
</div>
)
}
@@ -0,0 +1,361 @@
import type { ComponentType, SVGProps } from 'react'
import { AgentIcon, AnthropicIcon, GithubIcon, JiraIcon } from '@/components/icons'
import { CsvIcon, DocxIcon, MarkdownIcon, PdfIcon } from '@/components/icons/document-icons'
/**
* Shared data + geometry for the hero visual - the single source of truth the
* presentational stages render against. No JSX, no client code; pure data so it
* can be imported by both server- and client-side modules.
*
* The workflow is laid out in a fixed "design space" (px). Block positions and
* the SVG edge paths share these coordinates, so the `<svg>` overlay and the
* absolutely-positioned block cards line up exactly. {@link CANVAS} is the
* bounding box of that space; the stage scales it to fit the hero panel.
*/
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
/** A single field row inside a block card (label → value), faithful to the real WorkflowBlock. */
export interface BlockRow {
title: string
value: string
/** Optional provider mark shown left of the value (e.g. Anthropic for a Claude model). */
valueIcon?: IconComponent
}
/** A workflow block in design space. */
export interface BlockDef {
id: string
name: string
icon: IconComponent
/** Icon-tile fill - a brand-faithful color or a platform token (`var(--…)`). */
bgColor: string
/** White icon tiles (e.g. Jira) need a hairline so the mark stays visible. */
tileBorder?: boolean
/** Trigger blocks start the flow, so they render no incoming (left) handle. */
isTrigger?: boolean
/** Terminal blocks end the flow, so they render no outgoing (right) handle. */
isTerminal?: boolean
rows: BlockRow[]
/** Top-left corner in design space. */
x: number
y: number
}
/** Fixed block width, matching the real canvas (`BLOCK_DIMENSIONS.FIXED_WIDTH`). */
export const BLOCK_WIDTH = 250
/**
* Camera zoom while the workflow stage is focused on the first block. Chosen so
* the focused first block lands at the same on-screen width as the chat card
* (`BLOCK_WIDTH * SCALE ≈ 460`), so the chat card morphs straight into it with
* no jump. Shared by the chat stage (its morph target) and the workflow camera.
*/
export const WORKFLOW_FOCUS_SCALE = 1.25
/** Handle vertical offset from a block's top edge (matches the real WorkflowBlock). */
export const HANDLE_Y_OFFSET = 20
/**
* Design-space bounding box the stage scales to fit the panel. Sized to exactly
* bound the blocks below (GitHub/Jira at y=0, Agent's foot at ~y=203), so the
* stage's flex-centering centers the workflow with no stray margin.
*/
export const CANVAS = { width: 850, height: 206 } as const
/**
* GitHub → Agent → Jira. A gentle staircase: GitHub and Jira ride high, the
* Agent dips between them, so the two edges read as a clean down-then-up flow.
* The shape is left-right symmetric (Agent centered at x=425) and its bounding
* box matches {@link CANVAS}, keeping it centered in the panel.
*/
export const BLOCKS: BlockDef[] = [
{
id: 'github',
name: 'GitHub',
icon: GithubIcon,
bgColor: '#181C1E',
isTrigger: true,
rows: [{ title: 'Trigger', value: 'PR opened' }],
x: 0,
y: 0,
},
{
id: 'agent',
name: 'Agent',
icon: AgentIcon,
bgColor: 'var(--brand-accent)',
rows: [
{ title: 'Model', value: 'Claude', valueIcon: AnthropicIcon },
{ title: 'Task', value: 'Review PR' },
],
x: 300,
y: 96,
},
{
id: 'jira',
name: 'Jira',
icon: JiraIcon,
bgColor: '#FFFFFF',
tileBorder: true,
isTerminal: true,
rows: [{ title: 'Action', value: 'Create issue' }],
x: 600,
y: 0,
},
]
/** An ordered source → target connection between two blocks. */
export interface EdgeDef {
id: string
/** SVG path `d` in design space; `pathLength` is normalized to 1 by the renderer. */
d: string
}
/**
* Rounded orthogonal ("smoothstep") path from a source's right handle to a
* target's left handle, stepping at the horizontal midpoint with `r`-radius
* corners. Source/target points are taken at `block.x±width` and
* `block.y + HANDLE_Y_OFFSET`, matching where the handles render.
*/
export function smoothStep(sx: number, sy: number, tx: number, ty: number, r = 8): string {
const midX = (sx + tx) / 2
const dir = ty >= sy ? 1 : -1
return [
`M ${sx} ${sy}`,
`L ${midX - r} ${sy}`,
`Q ${midX} ${sy} ${midX} ${sy + dir * r}`,
`L ${midX} ${ty - dir * r}`,
`Q ${midX} ${ty} ${midX + r} ${ty}`,
`L ${tx} ${ty}`,
].join(' ')
}
function handlePoints(sourceId: string, targetId: string) {
const source = BLOCKS.find((b) => b.id === sourceId)
const target = BLOCKS.find((b) => b.id === targetId)
if (!source || !target) throw new Error(`Unknown block in edge ${sourceId}${targetId}`)
return {
sx: source.x + BLOCK_WIDTH,
sy: source.y + HANDLE_Y_OFFSET,
tx: target.x,
ty: target.y + HANDLE_Y_OFFSET,
}
}
export const EDGES: EdgeDef[] = (
[
['github', 'agent'],
['agent', 'jira'],
] as const
).map(([from, to]) => {
const { sx, sy, tx, ty } = handlePoints(from, to)
return { id: `${from}-${to}`, d: smoothStep(sx, sy, tx, ty) }
})
/**
* Unified hero scene geometry. The chat card is block 1 (GitHub), centered at
* the panel center; the rest of the workflow is placed relative to it, all at
* FOCUS scale (design × {@link WORKFLOW_FOCUS_SCALE}). The whole scene is then
* scaled/translated to the OVERVIEW to reveal the full workflow - so the SAME
* card element is continuously block 1 through the pull-out.
*
* Scene origin is the GitHub block's CENTER (which sits at the panel center).
*/
const GH_CENTER_X = BLOCK_WIDTH / 2
/** GitHub block half-height in design space (its content is ~77px tall). */
const GH_CENTER_Y = 38.5
const toSceneX = (dx: number) => (dx - GH_CENTER_X) * WORKFLOW_FOCUS_SCALE
const toSceneY = (dy: number) => (dy - GH_CENTER_Y) * WORKFLOW_FOCUS_SCALE
/** A satellite block (everything past block 1) placed in scene space. */
export interface SceneBlock {
block: BlockDef
/** Top-left in scene space (origin = panel center), at FOCUS scale. */
left: number
top: number
}
/**
* Block 1 (GitHub) in scene space. It's the morphed chat card - rendered
* content-only and clipped by the card's `overflow-hidden` - so its edge-handle
* nub is drawn separately at this position, matching where a satellite block
* (and its handle) would sit.
*/
export const SCENE_BLOCK1: SceneBlock = {
block: BLOCKS[0],
left: toSceneX(BLOCKS[0].x),
top: toSceneY(BLOCKS[0].y),
}
/** Blocks 2…N, positioned relative to the centered first block. */
export const SCENE_SATELLITES: SceneBlock[] = BLOCKS.slice(1).map((block) => ({
block,
left: toSceneX(block.x),
top: toSceneY(block.y),
}))
/** Edge paths in scene space (same connections as {@link EDGES}). */
export const SCENE_EDGES: EdgeDef[] = (
[
['github', 'agent'],
['agent', 'jira'],
] as const
).map(([from, to]) => {
const { sx, sy, tx, ty } = handlePoints(from, to)
return {
id: `${from}-${to}`,
d: smoothStep(toSceneX(sx), toSceneY(sy), toSceneX(tx), toSceneY(ty), 14),
}
})
/**
* Pull-out transform from FOCUS (block 1 centered, full size) to OVERVIEW (whole
* workflow centered, fit to panel). `SCALE` brings the FOCUS-scale scene down to
* the design overview (1.84 × 0.37 ≈ 0.68); the translate recenters the group -
* it matches the design overview's GitHub offset, so the framing is identical to
* the prior camera overview. Transform-origin is the panel center (block 1's
* center), so FOCUS is the identity transform (no measurement needed).
*/
export const SCENE_OVERVIEW_SCALE = 0.68 / WORKFLOW_FOCUS_SCALE
export const SCENE_OVERVIEW_TRANSLATE = { x: -204, y: -43 } as const
/** Camera scale while tracing the workflow edge-by-edge before the full zoom-out. */
export const SCENE_FOLLOW_SCALE = 0.86
/**
* Intermediate camera stops for the edge-follow pass. These keep the active
* destination block centered enough to read while preserving a little context
* around the incoming connection.
*/
export const SCENE_AGENT_FOCUS_TRANSLATE = { x: -323, y: -126 } as const
export const SCENE_JIRA_FOCUS_TRANSLATE = { x: -645, y: 0 } as const
/**
* The typed prompt, encoded as ordered atoms the typewriter reveals one at a
* time. A `char` atom is a single character; a `mention` atom pops in
* atomically as an inline icon-chip - exactly how the real input renders an
* `@GitHub` / `@Jira` mention.
*/
export type PromptAtom =
| { kind: 'char'; char: string }
| { kind: 'mention'; label: string; icon: IconComponent }
const PROMPT_SEGMENTS: Array<string | { label: string; icon: IconComponent }> = [
'Create me a ',
{ label: 'GitHub', icon: GithubIcon },
' PR review bot that connects to ',
{ label: 'Jira', icon: JiraIcon },
]
export const PROMPT_ATOMS: PromptAtom[] = PROMPT_SEGMENTS.flatMap((seg) =>
typeof seg === 'string'
? [...seg].map((char): PromptAtom => ({ kind: 'char', char }))
: [{ kind: 'mention', label: seg.label, icon: seg.icon } as PromptAtom]
)
/** Greeting shown above the input in the home state (matches the Mothership home). */
export const HOME_GREETING = 'What should we get done?'
/** Total reveal cadence for the typewriter, in ms per atom. */
export const TYPE_MS_PER_ATOM = 45
/**
* The Mothership's reply, typed out after it "thinks" (the cycle loader). Keeps
* the world voice - it dispatches an agent - and previews the workflow it's
* about to build, so the chat answer morphs naturally into the canvas below.
*/
export const ANSWER_TEXT = 'On it, dispatching an agent to review every PR and open a Jira issue.'
/** Reveal cadence for the answer typewriter (faster than a human; the AI types). */
export const ANSWER_MS_PER_CHAR = 18
/** White chat card grow after send, before the sent-message bubble is visible. */
export const SEND_BUBBLE_GROW_MS = 620
/** Delay the grey sent-message reveal until the card grow has visibly settled. */
export const SEND_BUBBLE_REVEAL_DELAY_MS = SEND_BUBBLE_GROW_MS + 260
/** Soft enter for the grey sent-message bubble once the card has room for it. */
export const SEND_BUBBLE_ENTER_MS = 280
/** Full send beat duration: grow, reveal, then a brief hold before loader slide. */
export const SEND_BUBBLE_HOLD_MS = SEND_BUBBLE_REVEAL_DELAY_MS + SEND_BUBBLE_ENTER_MS + 220
/** Knowledge-base name shown pre-filled in the create modal. */
export const KB_NAME = 'Product Docs'
/** A file shown dropping into the knowledge-base create modal. */
export interface KbFile {
name: string
size: string
icon: IconComponent
}
export const KB_FILES: KbFile[] = [
{ name: 'product-spec.pdf', size: '2.4 MB', icon: PdfIcon },
{ name: 'api-reference.md', size: '88 KB', icon: MarkdownIcon },
{ name: 'support-faq.docx', size: '1.1 MB', icon: DocxIcon },
{ name: 'pricing.csv', size: '12 KB', icon: CsvIcon },
]
/** Design-space bounding box for the embedding graph (its own SVG viewBox). */
export const GRAPH_VIEWBOX = { width: 340, height: 150 } as const
/** A node in the embedding graph. Hubs are larger, darker, and gently pulse. */
export interface GraphNode {
x: number
y: number
hub?: boolean
}
/**
* A single connected knowledge-graph laid out organically across the viewBox -
* three hubs with satellites, bridged into one mesh. Hand-placed (deterministic,
* SSR-stable) for a balanced, deliberate look rather than random scatter.
*/
export const GRAPH_NODES: GraphNode[] = [
{ x: 38, y: 66 },
{ x: 74, y: 104 },
{ x: 96, y: 44 },
{ x: 132, y: 82, hub: true },
{ x: 158, y: 40 },
{ x: 168, y: 116 },
{ x: 206, y: 70, hub: true },
{ x: 228, y: 38 },
{ x: 236, y: 112 },
{ x: 268, y: 72 },
{ x: 300, y: 104, hub: true },
{ x: 312, y: 58 },
{ x: 286, y: 40 },
{ x: 54, y: 96 },
{ x: 140, y: 122 },
{ x: 250, y: 96 },
]
/** Edges between {@link GRAPH_NODES} (index pairs) - one connected component. */
export const GRAPH_EDGES: Array<[number, number]> = [
[0, 2],
[0, 13],
[13, 1],
[1, 3],
[2, 3],
[2, 4],
[3, 4],
[3, 5],
[3, 14],
[5, 14],
[4, 6],
[6, 7],
[6, 15],
[6, 8],
[7, 12],
[12, 9],
[9, 15],
[8, 15],
[9, 10],
[8, 10],
[10, 11],
[11, 12],
[9, 11],
]
@@ -0,0 +1,128 @@
import { cn } from '@sim/emcn'
import Image from 'next/image'
import { LandingHeroHeader } from '@/app/(landing)/components/hero/components/hero-header'
import { HeroPlatformLoop } from '@/app/(landing)/components/hero/components/hero-platform-loop'
import {
LANDING_CONTENT_WIDTH,
LANDING_GUTTER,
LANDING_HERO_TOP_PADDING,
} from '@/app/(landing)/components/landing-layout'
import { TrustedBy } from '@/app/(landing)/components/trusted-by'
/**
* Landing hero - the only `<h1>` on the page.
*
* A single stacked flow (no split panels): headline and the sign-up row sit
* left-aligned at the top; below them a full-width media frame
* previews the platform UI; the customer-logo row closes the section centered
* underneath. The section is capped and centered at the shared `max-w-[1460px]`
* (`mx-auto`) with the `px-20 max-lg:px-8 max-sm:px-5` gutter so the headline
* starts on the navbar wordmark's vertical line.
*
* Text blocks stack a uniform 22px apart (`gap-[22px]`); the media frame and
* logo row carry their own larger top margins to read as separate bands.
*
* The sign-up row is the shared {@link HeroCta} - the single source of truth for
* the email-capture bar and the "Book a demo" / "Sign up" chips - reused
* verbatim by every platform and solutions hero so the primary CTA never drifts.
*
* The media frame: the painted landscape backdrop (`hero-backdrop.jpg`,
* rendered via `next/image` `fill` + `object-cover` with `priority` - it is the
* LCP element) behind a white window (a soft three-part shadow stack:
* `0 0 0 1px rgba(0,0,0,0.08)` ring in place of a CSS border, plus
* `0 2px 6px rgba(0,0,0,0.05)` contact and `0 4px 42px rgba(0,0,0,0.06)`
* ambient shadows; no browser toolbar) filled edge to edge by the REAL
* platform UI - a 2x
* screenshot (`hero-platform-ui.png`, 2560x1470: a 1280x735 layout shown in
* the 1080x620 window, so the UI reads at 84.4% - the "mini app" type scale
* cursor.com's demo window uses) of the chat-everywhere two-pane (seeded
* Mothership chat left, staged workflow right) captured from the
* `readme-tour-capture` route via
* `exports/readme-banner/capture-hero-platform.mjs`. The window is
* `rounded-[10px]` - matching cursor.com's demo window - and the shot's
* workspace container renders at the concentric inner radius `4px` (outer
* 10px - 6px gap; overridden at capture time from the chrome's 8px). Only the
* 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), 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
* loading fallback under the backdrop.
*
* The headline/CTA column shares its row with the right-aligned
* {@link HeroStat} (the "Global work done by Sim" figure with its vertical
* progress rail and staggered page-load entrance), hidden below `lg` where
* the row has no room.
*
* The shared {@link TrustedBy} block renders in its `row` layout - a centered
* muted label above a single centered row of bare wordmarks.
*
* Carries the sr-only ~50-word product summary for AI citation (CLAUDE.md → GEO).
*/
export function Hero() {
return (
<section
id='hero'
aria-labelledby='hero-heading'
className={cn(
'flex flex-col items-start gap-[22px] text-left',
LANDING_CONTENT_WIDTH,
LANDING_GUTTER,
LANDING_HERO_TOP_PADDING
)}
>
<p className='sr-only'>
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect
1,000+ integrations and every major LLM to create agents that automate real work, visually,
conversationally, or with code. Trusted by over 100,000 builders, SOC2 compliant, and
production-ready for teams of every size.
</p>
<LandingHeroHeader
headingId='hero-heading'
heading={
<>
Sim is your AI workspace <br />
for building agentic workflows.
</>
}
description='The open-source workspace where teams build, deploy, and manage AI agents.'
/>
<div
aria-hidden='true'
className='relative mt-[34px] aspect-[1300/720] w-full overflow-hidden rounded-lg bg-[var(--surface-3)] max-sm:aspect-[4/3]'
>
<Image
src='/landing/hero-backdrop.jpg'
alt=''
fill
priority
fetchPriority='high'
quality={90}
sizes='(max-width: 1460px) 100vw, 1300px'
className='object-cover'
/>
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 flex aspect-[1080/620] w-[83.08%] flex-col overflow-hidden rounded-[10px] bg-[var(--surface-1)] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_6px_0_rgba(0,0,0,0.05),0_4px_42px_0_rgba(0,0,0,0.06)]'>
<div className='relative flex-1'>
<Image
src='/landing/hero-platform-ui.png'
alt=''
fill
priority
sizes='(max-width: 1460px) 83vw, 1080px'
className='object-cover object-left-top'
/>
<HeroPlatformLoop />
</div>
</div>
</div>
<TrustedBy layout='row' className='mt-[42px] w-full max-sm:mt-6' />
</section>
)
}
@@ -0,0 +1 @@
export { Hero } from './hero'