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
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:
@@ -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'
|
||||
+176
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+164
@@ -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'
|
||||
+89
@@ -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>
|
||||
)
|
||||
}
|
||||
+32
@@ -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>
|
||||
)
|
||||
}
|
||||
+58
@@ -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'
|
||||
Reference in New Issue
Block a user