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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,288 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
Loader,
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalTitle,
ModalTrigger,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { requestJson } from '@/lib/api/client/request'
import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth'
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { captureClientEvent } from '@/lib/posthog/client'
import type { PostHogEventMap } from '@/lib/posthog/events'
import { getBrandConfig } from '@/ee/whitelabeling'
const logger = createLogger('AuthModal')
type AuthView = 'login' | 'signup'
interface AuthModalProps {
children: React.ReactNode
defaultView?: AuthView
source: PostHogEventMap['auth_modal_opened']['source']
}
type ProviderStatus = AuthProviderStatusResponse
let fetchPromise: Promise<AuthProviderStatusResponse> | null = null
const FALLBACK_STATUS: ProviderStatus = {
githubAvailable: false,
googleAvailable: false,
microsoftAvailable: false,
registrationDisabled: false,
}
const SOCIAL_BTN =
'relative flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--border-1)] text-[13.5px] text-[var(--text-primary)] transition-colors hover:bg-[var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50'
function fetchProviderStatus(): Promise<ProviderStatus> {
if (fetchPromise) return fetchPromise
fetchPromise = requestJson(getAuthProvidersContract, {})
.then(({ githubAvailable, googleAvailable, microsoftAvailable, registrationDisabled }) => ({
githubAvailable,
googleAvailable,
microsoftAvailable,
registrationDisabled,
}))
.catch(() => {
fetchPromise = null
return FALLBACK_STATUS
})
return fetchPromise
}
export function AuthModal({ children, defaultView = 'login', source }: AuthModalProps) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [view, setView] = useState<AuthView>(defaultView)
const [providerStatus, setProviderStatus] = useState<ProviderStatus | null>(null)
const [socialLoading, setSocialLoading] = useState<'github' | 'google' | 'microsoft' | null>(null)
const brand = getBrandConfig()
useEffect(() => {
fetchProviderStatus().then(setProviderStatus)
}, [])
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
/**
* Signup is unavailable when registration is disabled, so the shown view is
* clamped to login rather than mirrored into state through an effect.
*/
const effectiveView: AuthView =
view === 'signup' && providerStatus?.registrationDisabled ? 'login' : view
/**
* Tracks whether the visitor still wants the modal open. Cleared on dismiss so a
* provider-status fetch that resolves afterwards can't reopen it or re-fire the
* opened event.
*/
const openRequestedRef = useRef(false)
function openWithStatus(status: ProviderStatus) {
const hasModalContent =
status.githubAvailable || status.googleAvailable || status.microsoftAvailable || ssoEnabled
if (!hasModalContent) {
/** Close the loader (no-op if never opened) and route out; disabled registration sends signup to login. */
setOpen(false)
router.push(status.registrationDisabled || defaultView === 'login' ? '/login' : '/signup')
return
}
const initialView: AuthView =
defaultView === 'signup' && status.registrationDisabled ? 'login' : defaultView
setOpen(true)
setView(initialView)
captureClientEvent('auth_modal_opened', { view: initialView, source })
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) {
openRequestedRef.current = false
setOpen(false)
return
}
if (providerStatus) {
openWithStatus(providerStatus)
return
}
/** Status not loaded yet: open the loader immediately for responsiveness, then resolve. */
openRequestedRef.current = true
setOpen(true)
fetchProviderStatus().then((status) => {
setProviderStatus(status)
if (!openRequestedRef.current) return
/** Consume the request so a queued double-click can't open twice (no duplicate event). */
openRequestedRef.current = false
openWithStatus(status)
})
}
async function handleSocialLogin(provider: 'github' | 'google' | 'microsoft') {
setSocialLoading(provider)
try {
await client.signIn.social({ provider, callbackURL: '/workspace' })
} catch (error) {
logger.warn('Social sign-in did not complete', { provider, error })
} finally {
setSocialLoading(null)
}
}
function handleSSOLogin() {
setOpen(false)
router.push('/sso')
}
function handleEmailContinue() {
setOpen(false)
router.push(effectiveView === 'login' ? '/login' : '/signup')
}
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent size='sm' className='dark bg-[var(--bg)] text-[var(--text-primary)]'>
<ModalTitle className='sr-only'>
{effectiveView === 'login' ? 'Log in' : 'Create account'}
</ModalTitle>
<ModalDescription className='sr-only'>
{effectiveView === 'login' ? 'Sign in to your account' : 'Create a new account'}
</ModalDescription>
<div className='relative px-6 pt-6 pb-6'>
<ModalClose className='absolute top-6 right-6 rounded-sm opacity-70 transition-opacity hover:opacity-100'>
<X className='size-5 text-[var(--text-muted)]' />
<span className='sr-only'>Close</span>
</ModalClose>
{!providerStatus ? (
<div className='flex items-center justify-center py-16'>
<Loader className='size-5 text-[var(--text-muted)]' animate />
</div>
) : (
<>
<div className='flex flex-col items-start gap-6 pe-10'>
<Image
src={brand.logoUrl || '/logo/sim-landing.svg'}
alt={brand.name}
width={71}
height={22}
unoptimized
className='h-[22px] w-auto shrink-0 object-contain'
/>
<div className='flex flex-col gap-1 text-left'>
<p className='text-[22px] text-[color-mix(in_srgb,var(--text-muted)_60%,transparent)] leading-[125%] tracking-[0.02em]'>
Start building.
</p>
<h2 className='text-[22px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
{effectiveView === 'login' ? 'Log in to continue' : 'Create free account'}
</h2>
</div>
</div>
<div className='mt-6 space-y-3'>
{providerStatus.googleAvailable && (
<button
type='button'
onClick={() => handleSocialLogin('google')}
disabled={!!socialLoading}
className={SOCIAL_BTN}
>
<GoogleIcon className='absolute left-4 size-[18px] shrink-0' />
<span>
{socialLoading === 'google' ? 'Connecting...' : 'Continue with Google'}
</span>
</button>
)}
{providerStatus.microsoftAvailable && (
<button
type='button'
onClick={() => handleSocialLogin('microsoft')}
disabled={!!socialLoading}
className={SOCIAL_BTN}
>
<MicrosoftIcon className='absolute left-4 size-[18px] shrink-0' />
<span>
{socialLoading === 'microsoft' ? 'Connecting...' : 'Continue with Microsoft'}
</span>
</button>
)}
{providerStatus.githubAvailable && (
<button
type='button'
onClick={() => handleSocialLogin('github')}
disabled={!!socialLoading}
className={SOCIAL_BTN}
>
<GithubIcon className='absolute left-4 size-[18px] shrink-0' />
<span>
{socialLoading === 'github' ? 'Connecting...' : 'Continue with GitHub'}
</span>
</button>
)}
{ssoEnabled && (
<button type='button' onClick={handleSSOLogin} className={SOCIAL_BTN}>
Sign in with SSO
</button>
)}
</div>
{emailEnabled && (
<>
<div className='relative my-4'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-[var(--border)] border-t' />
</div>
<div className='relative flex justify-center text-[13.5px]'>
<span className='bg-[var(--bg)] px-4 text-[var(--text-muted)]'>Or</span>
</div>
</div>
<button
type='button'
onClick={handleEmailContinue}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] text-[13.5px] text-[var(--auth-primary-btn-text)] transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)]'
>
Continue with email
</button>
</>
)}
<div className='mt-4 text-center text-[13.5px]'>
<span className='text-[var(--text-muted)]'>
{effectiveView === 'login'
? "Don't have an account? "
: 'Already have an account? '}
</span>
{effectiveView === 'login' && providerStatus.registrationDisabled ? (
<span className='text-[var(--text-muted)]'>Registration is disabled</span>
) : (
<button
type='button'
onClick={() => setView(effectiveView === 'login' ? 'signup' : 'login')}
className='text-[var(--text-primary)] underline-offset-4 transition hover:text-[var(--text-primary)] hover:underline'
>
{effectiveView === 'login' ? 'Sign up' : 'Sign in'}
</button>
)}
</div>
</>
)}
</div>
</ModalContent>
</Modal>
)
}
@@ -0,0 +1,51 @@
import Link from 'next/link'
/**
* The canonical "Back to X" link for the landing family - a muted text link with
* a left chevron whose shaft draws in on hover. Single source of truth, reused by
* the blog post, integration, and model detail pages so the back affordance can't
* drift. Season is the global body font; color uses the platform muted→primary
* hover idiom.
*/
interface BackLinkProps {
href: string
label: string
}
export function BackLink({ href, label }: BackLinkProps) {
return (
<Link
href={href}
className='group/link inline-flex items-center gap-1.5 text-[var(--text-muted)] text-sm tracking-[0.02em] hover:text-[var(--text-primary)]'
>
<svg
className='size-3 shrink-0'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
>
<line
x1='1'
y1='5'
x2='10'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-right scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M6.5 2L3.5 5L6.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='group-hover/link:-translate-x-[30%] transition-transform duration-200 ease-out'
/>
</svg>
{label}
</Link>
)
}
@@ -0,0 +1 @@
export { BackLink } from './back-link'
@@ -0,0 +1,36 @@
/**
* The animated chevron used on landing link rows (models, integrations). On
* `group-hover/link` the leading line draws in and the arrowhead nudges right.
* Decorative, so `aria-hidden`.
*/
export function ChevronArrow() {
return (
<svg
className='size-3 shrink-0 text-[var(--text-muted)]'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
>
<line
x1='0'
y1='5'
x2='9'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-left scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M3.5 2L6.5 5L3.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='transition-transform duration-200 ease-out group-hover/link:translate-x-[30%]'
/>
</svg>
)
}
@@ -0,0 +1 @@
export { ChevronArrow } from './chevron-arrow'
@@ -0,0 +1,26 @@
import { Skeleton } from '@sim/emcn'
const AUTHOR_POST_SKELETON_COUNT = 4
/** Shared loading skeleton for a content section's author-profile route. */
export function ContentAuthorLoading() {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<div className='mb-6 flex items-center gap-3'>
<Skeleton className='size-[40px] rounded-full bg-[var(--surface-hover)]' />
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{Array.from({ length: AUTHOR_POST_SKELETON_COUNT }).map((_, i) => (
<div key={i} className='overflow-hidden rounded-lg border border-[var(--border)]'>
<Skeleton className='h-[160px] w-full rounded-none bg-[var(--surface-hover)]' />
<div className='p-3'>
<Skeleton className='mb-1 h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
))}
</div>
</main>
)
}
@@ -0,0 +1,76 @@
import Image from 'next/image'
import Link from 'next/link'
import type { ContentMeta } from '@/lib/content/schema'
import { JsonLd } from '@/app/(landing)/components/json-ld'
interface ContentAuthorPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
authorName?: string
authorAvatarUrl?: string
/** Posts already filtered down to this author. */
posts: ContentMeta[]
graphJsonLd?: Record<string, unknown>
}
/** Shared author-profile layout for a content section. */
export function ContentAuthorPage({
basePath,
authorName,
authorAvatarUrl,
posts,
graphJsonLd,
}: ContentAuthorPageProps) {
if (!authorName) {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='text-[32px] text-[var(--text-primary)]'>Author not found</h1>
</main>
)
}
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
{graphJsonLd && <JsonLd data={graphJsonLd} />}
<div className='mb-6 flex items-center gap-3'>
{authorAvatarUrl ? (
<Image
src={authorAvatarUrl}
alt={authorName}
width={40}
height={40}
className='rounded-full'
unoptimized
/>
) : null}
<h1 className='text-[32px] text-[var(--text-primary)] leading-tight'>{authorName}</h1>
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{posts.map((p) => (
<Link key={p.slug} href={`${basePath}/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[var(--border)]'>
<Image
src={p.ogImage}
alt={p.title}
width={600}
height={315}
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[var(--text-muted)] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='text-[var(--text-primary)] text-sm leading-tight'>{p.title}</div>
</div>
</div>
</Link>
))}
</div>
</main>
)
}
@@ -0,0 +1,2 @@
export { ContentAuthorLoading } from './content-author-loading'
export { ContentAuthorPage } from './content-author-page'
@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
import { cn } from '@sim/emcn'
import NextImage from 'next/image'
import { Lightbox } from '@/app/(landing)/components/lightbox'
interface ContentImageProps {
src: string
alt?: string
width?: number
height?: number
className?: string
}
/**
* Click-to-zoom image renderer used by MDX content (blog and library posts
* both compile through the same `mdxComponents` map in `@/lib/content/mdx`).
*/
export function ContentImage({
src,
alt = '',
width = 800,
height = 450,
className,
}: ContentImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
return (
<>
<NextImage
src={src}
alt={alt}
width={width}
height={height}
className={cn(
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
className
)}
sizes='(max-width: 768px) 100vw, 800px'
loading='lazy'
unoptimized
onClick={() => setIsLightboxOpen(true)}
/>
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
/>
</>
)
}
@@ -0,0 +1 @@
export { ContentImage } from './content-image'
@@ -0,0 +1,57 @@
import { Skeleton } from '@sim/emcn'
const FEATURED_SKELETON_COUNT = 3
const LIST_SKELETON_COUNT = 5
/** Shared loading skeleton for a content section's index/list route. */
export function ContentIndexLoading() {
return (
<section className='bg-[var(--bg)]'>
<div className='mx-auto w-full max-w-[1460px]'>
<div className='px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<Skeleton className='mb-5 h-[20px] w-[60px] rounded-md bg-[var(--surface-hover)]' />
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<Skeleton className='h-[40px] w-[240px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[18px] w-[320px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
<div className='mx-20 mt-8 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
<div className='h-px w-full bg-[var(--border)]' />
<div className='flex max-sm:flex-col'>
{Array.from({ length: FEATURED_SKELETON_COUNT }).map((_, i) => (
<div
key={i}
className='flex flex-1 flex-col gap-4 border-[var(--border)] p-6 max-sm:border-t max-sm:first:border-t-0 md:border-l md:first:border-l-0'
>
<Skeleton className='aspect-video w-full rounded-[5px] bg-[var(--surface-hover)]' />
<div className='flex flex-col gap-2'>
<Skeleton className='h-[12px] w-[60px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[20px] w-[80%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
))}
</div>
<div className='h-px w-full bg-[var(--border)]' />
{Array.from({ length: LIST_SKELETON_COUNT }).map((_, i) => (
<div key={i}>
<div className='flex items-center gap-6 p-6'>
<Skeleton className='hidden h-[14px] w-[120px] rounded-[4px] bg-[var(--surface-hover)] md:block' />
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<Skeleton className='h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-[90%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<Skeleton className='hidden h-[80px] w-[140px] rounded-[5px] bg-[var(--surface-hover)] sm:block' />
</div>
<div className='h-px w-full bg-[var(--border)]' />
</div>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,208 @@
import { ChipLink } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
import type { ContentMeta } from '@/lib/content/schema'
import { Cta } from '@/app/(landing)/components/cta/cta'
import { JsonLd } from '@/app/(landing)/components/json-ld'
const POSTS_PER_PAGE = 20
const FEATURED_COUNT = 3
interface ContentIndexPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
heading: string
subheading: string
/** All published posts for the section, unfiltered and unsorted. */
posts: ContentMeta[]
page: number
tag?: string
collectionJsonLd: Record<string, unknown>
}
/**
* Shared index/list layout for a content section (blog or library): section
* header, featured-post row, remaining posts list, and pagination. Both
* sections render this exact layout, parameterized by `basePath` and copy —
* see `.claude/rules/landing-seo-geo.md` for the filtered/paginated noindex
* policy this pairs with (`buildIndexMetadata` in `@/lib/content/seo`).
*/
export function ContentIndexPage({
basePath,
heading,
subheading,
posts,
page,
tag,
collectionJsonLd,
}: ContentIndexPageProps) {
const filtered = tag ? posts.filter((p) => p.tags.includes(tag)) : posts
const dateSorted = [...filtered].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
// Featured posts render once, in the page-1 featured row, regardless of
// where their date would otherwise place them — so they're carved out of
// the paginated pool up front rather than re-sorted to the front of page 1
// (which left them still reachable, and duplicated, on a later page).
const explicitlyFeatured = dateSorted.filter((p) => p.featured).slice(0, FEATURED_COUNT)
const featuredPosts =
explicitlyFeatured.length > 0 ? explicitlyFeatured : dateSorted.slice(0, FEATURED_COUNT)
const featuredSlugs = new Set(featuredPosts.map((p) => p.slug))
const paginated = dateSorted.filter((p) => !featuredSlugs.has(p.slug))
const totalPages = Math.max(1, Math.ceil(paginated.length / POSTS_PER_PAGE))
const start = (page - 1) * POSTS_PER_PAGE
const featured = page === 1 ? featuredPosts : []
const remaining = paginated.slice(start, start + POSTS_PER_PAGE)
const pageHref = (targetPage: number) =>
`${basePath}?page=${targetPage}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`
return (
<>
<section className='bg-[var(--bg)]'>
<JsonLd data={collectionJsonLd} />
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<h1 className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'>
{heading}
</h1>
<p className='max-w-[540px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
{subheading}
</p>
</div>
</div>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
<div className='border-[var(--border)] border-x'>
{featured.length > 0 && (
<>
<nav aria-label='Featured posts' className='flex flex-col sm:flex-row'>
{featured.map((p, index) => (
<Link
key={p.slug}
href={`${basePath}/${p.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
priority={index < 3}
fetchPriority={index === 0 ? 'high' : undefined}
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
})}
</span>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</nav>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{remaining.map((p) => (
<div key={p.slug}>
<Link
href={`${basePath}/${p.slug}`}
className='group flex items-start gap-6 p-6 transition-colors hover:bg-[var(--surface-hover)] md:items-center'
>
<span className='hidden w-[120px] shrink-0 pt-1 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:block'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:hidden'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<h3 className='text-[var(--text-primary)] text-base leading-tight tracking-[-0.01em] lg:text-lg'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
<div className='relative hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='140px'
className='object-cover'
unoptimized
/>
</div>
</Link>
<div className='h-px w-full bg-[var(--border)]' />
</div>
))}
{totalPages > 1 && (
<nav aria-label='Pagination' className='px-6 py-8'>
<div className='flex items-center justify-center gap-3'>
{page > 1 && (
<ChipLink
href={pageHref(page - 1)}
rel='prev'
className='border border-[var(--border-1)]'
>
Previous
</ChipLink>
)}
<span className='text-[var(--text-muted)] text-sm'>
Page {page} of {totalPages}
</span>
{page < totalPages && (
<ChipLink
href={pageHref(page + 1)}
rel='next'
className='border border-[var(--border-1)]'
>
Next
</ChipLink>
)}
</div>
</nav>
)}
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</section>
<div className='mt-[120px] max-sm:mt-16 max-lg:mt-[88px]'>
<Cta />
</div>
</>
)
}
@@ -0,0 +1,2 @@
export { ContentIndexLoading } from './content-index-loading'
export { ContentIndexPage } from './content-index-page'
@@ -0,0 +1,55 @@
import { Skeleton } from '@sim/emcn'
/** Shared loading skeleton for a content section's post-detail route. */
export function ContentPostLoading() {
return (
<article className='w-full bg-[var(--bg)]'>
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<div className='mb-6'>
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
<Skeleton className='aspect-[450/360] w-full rounded-[5px] bg-[var(--surface-hover)]' />
</div>
<div className='flex flex-1 flex-col justify-between'>
<div>
<Skeleton className='h-[44px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-2 h-[44px] w-[80%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-4 h-[18px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-2 h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='mt-6 flex items-center gap-6'>
<Skeleton className='h-[12px] w-[100px] rounded-[4px] bg-[var(--surface-hover)]' />
<div className='flex items-center gap-2'>
<Skeleton className='size-[20px] rounded-full bg-[var(--surface-hover)]' />
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
</div>
</div>
</div>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px]'>
<div className='mx-20 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
<div className='mx-auto max-w-[900px] px-6 py-16'>
<div className='space-y-4'>
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</article>
)
}
@@ -0,0 +1,179 @@
import { Avatar, AvatarFallback, AvatarImage } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
import { FAQ } from '@/lib/content/faq'
import type { ContentMeta, ContentPost } from '@/lib/content/schema'
import { BackLink } from '@/app/(landing)/components/back-link'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { ShareButton } from '@/app/(landing)/components/share-button'
interface ContentPostPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
/** Label for the back-to-index link, e.g. "Back to Blog". */
backLabel: string
post: ContentPost
related: ContentMeta[]
graphJsonLd: Record<string, unknown>
shareUrl: string
}
/**
* Shared post-detail layout for a content section (blog or library): header
* with cover image/title/authors/share, MDX article body + FAQ, and related
* posts. Both sections render this exact layout, parameterized by `basePath`.
*/
export function ContentPostPage({
basePath,
backLabel,
post,
related,
graphJsonLd,
shareUrl,
}: ContentPostPageProps) {
const Article = post.Content
return (
<article className='w-full bg-[var(--bg)]' itemScope itemType='https://schema.org/TechArticle'>
<JsonLd data={graphJsonLd} />
<header className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<div className='mb-6'>
<BackLink href={basePath} label={backLabel} />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
<div className='relative w-full overflow-hidden rounded-[5px]'>
<Image
src={post.ogImage}
alt={post.title}
width={450}
height={360}
className='h-auto w-full'
sizes='(max-width: 768px) 100vw, 450px'
priority
fetchPriority='high'
itemProp='image'
unoptimized
/>
</div>
</div>
<div className='flex flex-1 flex-col justify-between'>
<div>
<h1
className='text-balance text-[28px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em] sm:text-[36px] md:text-[44px] lg:text-[52px]'
itemProp='headline'
>
{post.title}
</h1>
<p className='mt-4 text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'>
{post.description}
</p>
</div>
<div className='mt-6 flex items-center gap-6'>
<time
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'
dateTime={post.date}
itemProp='datePublished'
>
{new Date(post.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</time>
<meta itemProp='dateModified' content={post.updated ?? post.date} />
<div className='flex items-center gap-3'>
{(post.authors || [post.author]).map((a) => (
<div key={a?.name} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-5'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] hover:text-[var(--text-primary)]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
<div className='ml-auto'>
<ShareButton url={shareUrl} title={post.title} />
</div>
</div>
</div>
</div>
</header>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
<div className='border-[var(--border)] border-x'>
<div className='mx-auto max-w-[900px] px-6 py-16' itemProp='articleBody'>
<div className='prose prose-lg max-w-none prose-blockquote:border-[var(--border-1)] prose-hr:border-[var(--border)] prose-headings:font-season prose-a:text-[var(--text-primary)] prose-blockquote:text-[var(--text-muted)] prose-code:text-[var(--text-primary)] prose-headings:text-[var(--text-primary)] prose-li:text-[var(--text-body)] prose-p:text-[var(--text-body)] prose-strong:text-[var(--text-primary)] prose-headings:tracking-[-0.02em]'>
<Article />
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
</div>
</div>
{related.length > 0 && (
<>
<div className='h-px w-full bg-[var(--border)]' />
<nav aria-label='Related posts' className='flex flex-col sm:flex-row'>
{related.map((p) => (
<Link
key={p.slug}
href={`${basePath}/${p.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
loading='lazy'
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
})}
</span>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</nav>
</>
)}
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
<meta itemProp='publisher' content='Sim' />
<meta itemProp='inLanguage' content='en-US' />
<meta itemProp='keywords' content={post.tags.join(', ')} />
{post.wordCount && <meta itemProp='wordCount' content={String(post.wordCount)} />}
</article>
)
}
@@ -0,0 +1,2 @@
export { ContentPostLoading } from './content-post-loading'
export { ContentPostPage } from './content-post-page'
@@ -0,0 +1,21 @@
import { Skeleton } from '@sim/emcn'
const TAG_SKELETON_COUNT = 12
/** Shared loading skeleton for a content section's tags route. */
export function ContentTagsLoading() {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<Skeleton className='mb-6 h-[32px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
<div className='flex flex-wrap gap-3'>
{Array.from({ length: TAG_SKELETON_COUNT }).map((_, i) => (
<Skeleton
key={i}
className='h-[30px] rounded-full bg-[var(--surface-hover)]'
style={{ width: `${60 + (i % 4) * 24}px` }}
/>
))}
</div>
</main>
)
}
@@ -0,0 +1,34 @@
import { ChipLink } from '@sim/emcn'
import type { TagWithCount } from '@/lib/content/schema'
import { JsonLd } from '@/app/(landing)/components/json-ld'
interface ContentTagsPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
tags: TagWithCount[]
breadcrumbJsonLd: Record<string, unknown>
}
/** Shared "browse by tag" layout for a content section. */
export function ContentTagsPage({ basePath, tags, breadcrumbJsonLd }: ContentTagsPageProps) {
return (
<section className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<JsonLd data={breadcrumbJsonLd} />
<h1 className='mb-6 text-[32px] text-[var(--text-primary)] leading-tight'>Browse by tag</h1>
<div className='flex flex-wrap gap-3'>
<ChipLink href={basePath} className='border border-[var(--border-1)]'>
All
</ChipLink>
{tags.map((t) => (
<ChipLink
key={t.tag}
href={`${basePath}?tag=${encodeURIComponent(t.tag)}`}
className='border border-[var(--border-1)]'
>
{t.tag} ({t.count})
</ChipLink>
))}
</div>
</section>
)
}
@@ -0,0 +1,2 @@
export { ContentTagsLoading } from './content-tags-loading'
export { ContentTagsPage } from './content-tags-page'
@@ -0,0 +1,41 @@
import { ChipLink } from '@sim/emcn'
import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants'
/**
* Landing pre-footer CTA - the page's final conversion band. A tall, centered
* closing band with a large headline over two pill actions - a primary
* "Get started" routing to sign-up and an outline "Contact sales" routing to
* the demo-booking page.
*
* The band carries no vertical padding of its own: its spacious closing moment
* comes from the uniform inter-section `gap` (owned by the `<main>` flex in
* `landing.tsx`) above it and the `Footer`'s top margin below it. The headline
* mirrors the hero `<h1>` exactly (48px / `leading-[1.1]` and the same responsive
* ramp), so the page opens and closes on the same display size. Horizontal
* padding (`px-20`) matches every section above, and the section is capped and
* centered at the shared `max-w-[1460px]`.
*/
export function Cta() {
return (
<section
id='cta'
aria-labelledby='cta-heading'
className='mx-auto flex w-full max-w-[1460px] flex-col items-center gap-[22px] px-20 text-center max-sm:px-5 max-lg:px-8'
>
<h2
id='cta-heading'
className='max-w-[860px] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
>
Build your first agent today.
</h2>
<div className='flex items-center gap-1'>
<ChipLink variant='primary' href={SIGNUP_HREF}>
Get started
</ChipLink>
<ChipLink href={DEMO_HREF} className='border border-[var(--border-1)]'>
Contact sales
</ChipLink>
</div>
</section>
)
}
@@ -0,0 +1,20 @@
import { ThinkingLoader } from '@/components/ui'
import { WorkflowShowcase } from '@/app/(landing)/components/features/components/build-callout/components/workflow-showcase'
/**
* The Build beat's callout - the finished workflow canvas centered on the
* card's solid grey stage, with the goo cycle loader phasing through its
* world-state phrases in the bottom-left corner. The Mothership chat loop
* (`components/build-chat-animation`) is parked here unwired, kept for reuse
* on another surface.
*/
export function BuildCallout() {
return (
<div aria-hidden='true' className='pointer-events-none absolute inset-0'>
<WorkflowShowcase />
<div className='absolute bottom-8 left-8 max-sm:bottom-4 max-sm:left-4'>
<ThinkingLoader size={22} phase className='text-[var(--text-body)]' />
</div>
</div>
)
}
@@ -0,0 +1,361 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { Blimp } from '@sim/emcn/icons'
import { ArrowUp, Mic, Paperclip, Plus, Slash } from 'lucide-react'
import { ThinkingLoader } from '@/components/ui'
const PROMPT = 'Build a workflow to schedule and publish posts to my X account.'
const REPLY =
'On it — building a workflow with a schedule trigger, a drafting agent, and an X publish step.'
const REPLY_WORDS = REPLY.split(' ')
const TYPE_START_MS = 900
const TYPE_CHAR_MS = 40
const SEND_MS = 3800
const THINKING_DONE_MS = 6000
const REPLY_START_DELAY_MS = 420
const REPLY_WORD_MS = 95
const MERGE_MS = 9000
const LOOP_MS = 10400
/** Resting gap between the chat surface and the composer once the split settles. */
const SPLIT_GAP_PX = 10
/** Goo blur decay window — the liquid neck between the boxes thins and snaps over this span. */
const GOO_DECAY_MS = 850
/** Peak feGaussianBlur stdDeviation at the send beat. */
const GOO_MAX_BLUR = 16
/** Blur decay tick; stepped so the blur eases out on a (1-t)^2 curve. */
const GOO_STEP_MS = 40
/**
* Chat-surface transition set while opening: geometry travels with the goo
* split (500ms), the surface itself fades in fast (250ms), and the hairline
* ring + elevation crisp in only after the liquid split settles (300ms fade,
* 550ms delay).
*/
const CHAT_SURFACE_TRANSITION_OPEN =
'[transition:height_500ms_ease-out,transform_500ms_ease-out,margin-bottom_500ms_ease-out,opacity_250ms_ease-out,box-shadow_300ms_ease-out_550ms]'
/**
* Chat-surface transition set while merging back: same geometry travel, but
* the ring + elevation drop immediately and the surface fades out fast so the
* goo ghost carries the collapse into the composer.
*/
const CHAT_SURFACE_TRANSITION_MERGE =
'[transition:height_500ms_ease-out,transform_500ms_ease-out,margin-bottom_500ms_ease-out,opacity_250ms_ease-out,box-shadow_200ms_ease-out]'
type BuildChatPhase =
| 'idle'
| 'typing'
| 'thinking'
| 'replyPreparing'
| 'replying'
| 'complete'
| 'merging'
/**
* Decorative Build-card Mothership loop. It mirrors the studio choreography
* with local timers and CSS transitions so the landing page avoids motion
* dependencies while preserving the product-window feel.
*
* The composer is a permanent fixture; the send beat gooey-morphs the chat
* surface out of it, and the loop's final beat gooey-merges it back down in.
* An SVG goo filter (state-driven blur → alpha-contrast matrix → atop
* composite) is applied to an aria-hidden ghost layer of two solid white
* rects mirroring the real boxes (both measured with a ResizeObserver) —
* never to the real UI, which stays crisp above it. The filtered silhouette
* renders a liquid meniscus that necks and snaps as the boxes separate (and
* re-welds as they merge); each morph's blur decays on a (1-t)^2 curve over
* 850ms. The chat surface's ring + shadow crisp in only after the split and
* drop instantly at the merge, so the ghost carries the collapse.
*/
export function BuildChatAnimation() {
const [phase, setPhase] = useState<BuildChatPhase>('idle')
const [typedChars, setTypedChars] = useState(0)
const [revealedWords, setRevealedWords] = useState(0)
const [reducedMotion, setReducedMotion] = useState(false)
const [morphing, setMorphing] = useState(false)
const [gooBlur, setGooBlur] = useState(0)
const [chatContentH, setChatContentH] = useState(0)
const [composerH, setComposerH] = useState(94)
const chatContentRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const content = chatContentRef.current
const composer = composerRef.current
if (!content || !composer) return
const measure = () => {
setChatContentH(content.offsetHeight)
setComposerH(composer.offsetHeight)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(content)
observer.observe(composer)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!morphing) return
let progress = 0
const interval = setInterval(() => {
progress += GOO_STEP_MS / GOO_DECAY_MS
if (progress >= 1) {
setGooBlur(0)
setMorphing(false)
clearInterval(interval)
return
}
setGooBlur(GOO_MAX_BLUR * (1 - progress) ** 2)
}, GOO_STEP_MS)
return () => clearInterval(interval)
}, [morphing])
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
const timeouts: ReturnType<typeof setTimeout>[] = []
const intervals: ReturnType<typeof setInterval>[] = []
const clearScheduled = () => {
for (const timeout of timeouts) clearTimeout(timeout)
for (const interval of intervals) clearInterval(interval)
timeouts.length = 0
intervals.length = 0
}
const showFinished = () => {
setReducedMotion(true)
setPhase('complete')
setTypedChars(0)
setRevealedWords(REPLY_WORDS.length)
setMorphing(false)
setGooBlur(0)
}
const startTyping = () => {
setPhase('typing')
const startedAt = performance.now()
const interval = setInterval(() => {
const elapsed = performance.now() - startedAt
const nextChars = Math.min(Math.floor(elapsed / TYPE_CHAR_MS) + 1, PROMPT.length)
setTypedChars(nextChars)
if (nextChars >= PROMPT.length) clearInterval(interval)
}, TYPE_CHAR_MS)
intervals.push(interval)
}
const startReply = () => {
setPhase('replying')
const startedAt = performance.now()
const interval = setInterval(() => {
const elapsed = performance.now() - startedAt
const nextWords = Math.min(Math.floor(elapsed / REPLY_WORD_MS) + 1, REPLY_WORDS.length)
setRevealedWords(nextWords)
if (nextWords >= REPLY_WORDS.length) {
clearInterval(interval)
setPhase('complete')
}
}, REPLY_WORD_MS)
intervals.push(interval)
}
const runLoop = () => {
clearScheduled()
setReducedMotion(false)
setPhase('idle')
setTypedChars(0)
setRevealedWords(0)
setMorphing(false)
setGooBlur(0)
timeouts.push(setTimeout(startTyping, TYPE_START_MS))
timeouts.push(
setTimeout(() => {
setPhase('thinking')
setTypedChars(0)
setMorphing(true)
setGooBlur(GOO_MAX_BLUR)
}, SEND_MS)
)
timeouts.push(setTimeout(() => setPhase('replyPreparing'), THINKING_DONE_MS))
timeouts.push(setTimeout(startReply, THINKING_DONE_MS + REPLY_START_DELAY_MS))
timeouts.push(
setTimeout(() => {
setPhase('merging')
setMorphing(true)
setGooBlur(GOO_MAX_BLUR)
}, MERGE_MS)
)
timeouts.push(setTimeout(runLoop, LOOP_MS))
}
const syncMotionPreference = () => {
clearScheduled()
if (media.matches) {
showFinished()
return
}
runLoop()
}
syncMotionPreference()
media.addEventListener('change', syncMotionPreference)
return () => {
media.removeEventListener('change', syncMotionPreference)
clearScheduled()
}
}, [])
const composerText = phase === 'typing' ? PROMPT.slice(0, typedChars) : ''
const chatOpen = reducedMotion || !['idle', 'typing', 'merging'].includes(phase)
const thinkingPhraseVisible = phase === 'thinking'
const replyVisible =
reducedMotion || phase === 'replyPreparing' || phase === 'replying' || phase === 'complete'
const replyComplete = reducedMotion || phase === 'complete'
const replyText = REPLY_WORDS.slice(0, reducedMotion ? REPLY_WORDS.length : revealedWords).join(
' '
)
return (
<div className='absolute inset-0 flex items-end justify-start p-10 max-sm:px-3 max-sm:py-0 max-lg:items-center max-lg:justify-center max-lg:px-8 max-lg:py-8'>
<svg width='0' height='0' aria-hidden='true' className='absolute'>
<defs>
<filter id='build-callout-goo'>
<feGaussianBlur in='SourceGraphic' stdDeviation={gooBlur} result='blur' />
<feColorMatrix
in='blur'
type='matrix'
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -8'
result='goo'
/>
<feComposite in='SourceGraphic' in2='goo' operator='atop' />
</filter>
</defs>
</svg>
<div className='relative h-[310px] w-full max-w-[560px] max-sm:h-[270px] max-sm:max-w-[430px]'>
{!reducedMotion && (
<div
aria-hidden='true'
className='pointer-events-none absolute inset-0 z-0 flex flex-col justify-end [filter:url(#build-callout-goo)_drop-shadow(0_1px_3px_rgba(28,40,64,0.10))]'
>
<div
className={cn(
'w-full rounded-2xl bg-[var(--white)] transition-[height,transform,margin-bottom] duration-500 ease-out motion-reduce:transition-none',
chatOpen ? 'translate-y-0' : 'translate-y-5'
)}
style={{
height: chatOpen ? chatContentH : 0,
marginBottom: chatOpen ? SPLIT_GAP_PX : 0,
}}
/>
<div className='w-full rounded-2xl bg-[var(--white)]' style={{ height: composerH }} />
</div>
)}
<div className='relative z-10 flex h-full flex-col justify-end'>
<section
className={cn(
'w-full overflow-hidden rounded-2xl bg-[var(--white)]',
!reducedMotion &&
(chatOpen ? CHAT_SURFACE_TRANSITION_OPEN : CHAT_SURFACE_TRANSITION_MERGE),
chatOpen
? 'translate-y-0 opacity-100 shadow-[0_24px_80px_color-mix(in_srgb,var(--text-primary)_14%,transparent),0_0_0_1px_var(--border-1)]'
: 'translate-y-5 opacity-0 shadow-[0_24px_80px_transparent,0_0_0_1px_transparent]'
)}
style={{
height: chatOpen ? chatContentH : 0,
marginBottom: chatOpen ? SPLIT_GAP_PX : 0,
}}
>
<div ref={chatContentRef} className='flex flex-col gap-4 p-4 max-sm:gap-2.5 max-sm:p-3'>
<div className='ml-auto max-w-[78%] rounded-lg bg-[var(--surface-5)] px-3 py-2 text-[15px] text-[var(--text-primary)] leading-[1.45] max-sm:max-w-[86%] max-sm:text-[12px] max-sm:leading-[1.35]'>
{PROMPT}
</div>
<div className='relative min-h-[34px]'>
<div
className={cn(
'absolute inset-0 flex items-center transition-opacity duration-300 ease-out motion-reduce:transition-none',
thinkingPhraseVisible ? 'opacity-100' : 'opacity-0'
)}
>
<ThinkingLoader
size={20}
startVariant='corners'
phase
labelRatio={0.66}
className='text-[var(--text-body)]'
/>
</div>
<div
className={cn(
'flex items-start gap-2.5 text-[15px] text-[var(--text-primary)] leading-[1.5] transition-opacity duration-300 ease-out motion-reduce:transition-none max-sm:text-[12px] max-sm:leading-[1.4]',
replyVisible ? 'opacity-100' : 'opacity-0'
)}
>
<span className='relative mt-0.5 size-[20px] shrink-0'>
<span
className={cn(
'absolute inset-0 flex items-center justify-center transition-opacity duration-200 ease-out motion-reduce:transition-none',
replyComplete ? 'opacity-0' : 'opacity-100'
)}
>
<ThinkingLoader size={20} startVariant='corners' />
</span>
<span
className={cn(
'absolute inset-0 flex items-center justify-center transition-opacity duration-200 ease-out motion-reduce:transition-none',
replyComplete ? 'opacity-100' : 'opacity-0'
)}
>
<Blimp className='size-[18px] text-[var(--text-icon)]' />
</span>
</span>
<p className='flex-1'>{replyText}</p>
</div>
</div>
</div>
</section>
<div
ref={composerRef}
className='h-[94px] w-full rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 shadow-[0_18px_60px_color-mix(in_srgb,var(--text-primary)_12%,transparent)]'
>
<p
className={cn(
'min-h-[42px] px-1.5 pt-1 text-[15px] leading-[1.35] max-sm:text-[13px]',
composerText ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
)}
>
{composerText || 'Send message to Sim'}
</p>
<div className='flex items-center gap-1.5'>
<span className='flex size-[28px] items-center justify-center rounded-full'>
<Plus className='size-[16px] text-[var(--text-icon)]' />
</span>
<span className='flex size-[28px] items-center justify-center rounded-full'>
<Paperclip className='size-[16px] text-[var(--text-icon)]' />
</span>
<span className='flex size-[28px] items-center justify-center rounded-full'>
<Slash className='size-[16px] text-[var(--text-icon)]' />
</span>
<span className='ml-auto flex items-center gap-1.5'>
<span className='flex size-[28px] items-center justify-center rounded-full'>
<Mic className='size-[16px] text-[var(--text-icon)]' />
</span>
<span className='flex size-[28px] items-center justify-center rounded-full bg-[#808080]'>
<ArrowUp className='size-[16px] text-[var(--surface-1)]' />
</span>
</span>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { BuildChatAnimation } from './build-chat-animation'
@@ -0,0 +1 @@
export { WorkflowShowcase } from './workflow-showcase'
@@ -0,0 +1,138 @@
import {
AgentIcon,
AnthropicIcon,
GmailIcon,
LinearIcon,
SlackIcon,
TableIcon,
} from '@/components/icons'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/**
* Design-space geometry for the Build card's workflow showcase - a
* support-triage pipeline flowing LEFT TO RIGHT: two triggers (a Gmail inbox
* and a Slack channel) converge on a triage agent, which fans out to four
* destinations - Linear, an eng escalation, a customer reply, and Tables.
* Rows carry concrete values (not placeholders) so the canvas reads like a
* configured workflow.
*
* Column geometry: three columns at x=0 / x=370 / x=740 on a 990-wide canvas.
* The triggers straddle the agent's row; the four outputs stack down the
* right column, spanning y=20-666 on a 686-tall canvas. The bounding box
* exactly hugs the blocks, so the stage's flex-centering shows the whole
* flow clean and uncut. Edges run from right handles to left handles at
* `HANDLE_Y_OFFSET`, matching the real editor's horizontal layout.
*
* Icon tiles follow the platform rule: grey text-ramp tiles for first-party
* blocks, brand colors only for REAL third-party marks, and white bordered
* tiles for marks that carry their own colors.
*/
export const SHOWCASE_BLOCKS: BlockDef[] = [
{
id: 'gmail-trigger',
name: 'New support email',
icon: GmailIcon,
bgColor: '#FFFFFF',
tileBorder: true,
isTrigger: true,
rows: [
{ title: 'From', value: 'Customers' },
{ title: 'Filter', value: 'Unread' },
],
x: 0,
y: 150,
},
{
id: 'slack-trigger',
name: 'New #support post',
icon: SlackIcon,
bgColor: '#611F69',
isTrigger: true,
rows: [
{ title: 'Channel', value: '#support' },
{ title: 'Event', value: 'New message' },
],
x: 0,
y: 424,
},
{
id: 'triage',
name: 'Triage request',
icon: AgentIcon,
bgColor: 'var(--text-primary)',
rows: [
{ title: 'Model', value: 'Claude', valueIcon: AnthropicIcon },
{ title: 'Knowledge', value: 'Help center' },
{ title: 'Instructions', value: 'Triage + draft' },
],
x: 370,
y: 277,
},
{
id: 'linear',
name: 'File bug',
icon: LinearIcon,
bgColor: '#FFFFFF',
tileBorder: true,
isTerminal: true,
rows: [
{ title: 'Team', value: 'Platform' },
{ title: 'Priority', value: 'From triage' },
],
x: 740,
y: 20,
},
{
id: 'escalate',
name: 'Escalate to eng',
icon: SlackIcon,
bgColor: '#611F69',
isTerminal: true,
rows: [
{ title: 'Channel', value: '#eng-oncall' },
{ title: 'When', value: 'Urgent' },
],
x: 740,
y: 200,
},
{
id: 'reply',
name: 'Send reply',
icon: GmailIcon,
bgColor: '#FFFFFF',
tileBorder: true,
isTerminal: true,
rows: [
{ title: 'To', value: 'Customer' },
{ title: 'Tone', value: 'Friendly' },
],
x: 740,
y: 380,
},
{
id: 'tables',
name: 'Log ticket',
icon: TableIcon,
bgColor: 'var(--text-body)',
isTerminal: true,
rows: [
{ title: 'Table', value: 'Tickets' },
{ title: 'Operation', value: 'Insert' },
],
x: 740,
y: 560,
},
]
/** Source → target pairs; every edge is drawn (the flow renders finished). */
export const SHOWCASE_EDGES: ReadonlyArray<readonly [string, string]> = [
['gmail-trigger', 'triage'],
['slack-trigger', 'triage'],
['triage', 'linear'],
['triage', 'escalate'],
['triage', 'reply'],
['triage', 'tables'],
]
/** Design-space bounding box of the layout above. */
export const SHOWCASE_CANVAS = { width: 990, height: 686 } as const
@@ -0,0 +1,73 @@
import {
SHOWCASE_BLOCKS,
SHOWCASE_CANVAS,
SHOWCASE_EDGES,
} from '@/app/(landing)/components/features/components/build-callout/components/workflow-showcase/showcase-data'
import { WorkflowBlock } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block'
import {
BLOCK_WIDTH,
HANDLE_Y_OFFSET,
smoothStep,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/**
* The Build card's centerpiece - the left-to-right support-triage pipeline
* from {@link SHOWCASE_BLOCKS} / {@link SHOWCASE_EDGES} rendered raw on the
* card's solid grey stage, every block on canvas and every edge drawn. Static
* and server-rendered; blocks reuse the hero-visual's {@link WorkflowBlock}
* (the horizontal-flow card with left/right handle nubs).
*
* The 990x686 design canvas renders at fixed per-breakpoint scales - 0.62
* (1280+), 0.38 (below `xl`, where the side-by-side card leaves the
* aspect-locked stage narrow), 0.45 (below `lg`, stacked full-width), 0.29
* (below `sm`) - each chosen as the largest zoom that keeps the WHOLE flow
* inside that tier's media stage, so the graph sits centered and uncut. Each
* sizer tier is the canvas dimensions times that tier's scale.
*/
export function WorkflowShowcase() {
const byId = new Map(SHOWCASE_BLOCKS.map((block) => [block.id, block]))
return (
<div className='absolute inset-0 flex items-center justify-center'>
<div className='relative h-[425px] w-[614px] shrink-0 max-sm:h-[199px] max-sm:w-[287px] max-lg:h-[309px] max-lg:w-[446px] max-xl:h-[261px] max-xl:w-[376px]'>
<div className='absolute top-0 left-0 h-[686px] w-[990px] origin-top-left [transform:scale(0.62)] max-sm:[transform:scale(0.29)] max-lg:[transform:scale(0.45)] max-xl:[transform:scale(0.38)]'>
<svg
className='absolute inset-0 overflow-visible'
width={SHOWCASE_CANVAS.width}
height={SHOWCASE_CANVAS.height}
viewBox={`0 0 ${SHOWCASE_CANVAS.width} ${SHOWCASE_CANVAS.height}`}
fill='none'
>
{SHOWCASE_EDGES.map(([from, to]) => {
const source = byId.get(from)
const target = byId.get(to)
if (!source || !target) return null
return (
<path
key={`${from}-${to}`}
d={smoothStep(
source.x + BLOCK_WIDTH,
source.y + HANDLE_Y_OFFSET,
target.x,
target.y + HANDLE_Y_OFFSET
)}
stroke='var(--workflow-edge)'
strokeWidth={2}
strokeLinecap='round'
/>
)
})}
</svg>
{SHOWCASE_BLOCKS.map((block) => (
<div
key={block.id}
className='absolute'
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
>
<WorkflowBlock block={block} />
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { BuildCallout } from './build-callout'
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
const CALLOUT_FADE =
'[-webkit-mask-image:linear-gradient(to_bottom,#000_72%,transparent)] [mask-image:linear-gradient(to_bottom,#000_72%,transparent)]'
interface CalloutFrameProps {
/** Width/layout for the panel (e.g. `w-[340px]`). */
className?: string
/** Sizing for the inner body (e.g. `h-[300px]`). */
bodyClassName?: string
/** Dissolve the body's lower edge so the surface reads as continuing below. */
fade?: boolean
children: ReactNode
}
/**
* The shared chrome for a callout: an elevated panel that lifts a real Sim UI
* surface off the backdrop, wearing the hero platform window's exact chrome -
* 10px radius, `--surface-1` fill, and the hairline-ring + layered soft shadow
* - so every floating window on the page reads as one family. Optionally fades
* its body's foot so a long surface dissolves rather than ends on a hard edge.
*/
export function CalloutFrame({ className, bodyClassName, fade, children }: CalloutFrameProps) {
return (
<div
className={cn(
'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)]',
className
)}
>
<div className={cn('relative overflow-hidden', fade && CALLOUT_FADE, bodyClassName)}>
{children}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { CalloutFrame } from './callout-frame'
@@ -0,0 +1,128 @@
import type { ReactNode } from 'react'
import { ChipTag, cn } from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
interface FeatureCardProps {
/** Capability name shown as a chip tag pinned to the card's top-right corner. */
eyebrow: string
/** The beat's headline (`<h3>` - the section owns the single `<h2>`). */
title: string
/** Supporting copy beneath the headline. */
description: string
/** Optional trailing link (e.g. the feature's platform page). */
href?: string
/** Label for {@link href}. */
linkLabel?: string
/**
* Backdrop image under the floating callout (public path). Omit for a solid
* stage in the hero visual's light grey (`--surface-3`).
*/
backdropSrc?: string
/**
* Which side the media stage sits on from `lg` up (cards alternate down the
* section, Cursor-style). Below `lg` the card always stacks media-first.
*/
mediaSide?: 'left' | 'right'
/**
* Square the card's bottom corners so its bottom edge merges with a
* full-bleed divider drawn at the same line (the section's last card).
*/
flushBottom?: boolean
/** The elevated real-UI callout floating over the backdrop. */
children: ReactNode
}
/**
* Cursor-style feature card - one large OUTLINED container (a light
* `--border` hairline on a transparent ground, no grey fill) holding a media
* stage (a painted backdrop with the beat's real-UI callout floating over it)
* and a vertically-centered copy column: `<h3>`, muted description, and an
* optional arrow link. `mediaSide` picks which side the media sits on so the
* cards can alternate down the section. The beat's name sits as a borderless
* grey-filled {@link ChipTag} pinned to the card's top corner on the COPY
* side (top-right when media is left, top-left when media is right), just
* inside the outline - never floating over the media image.
*
* Below `lg` the card stacks - media on top, copy beneath - and the media
* shortens so the card stays scannable in the compact grid. The pinned chip
* would overlap the full-width media there, so it relocates INTO the copy
* column, sitting above the `<h3>` as an in-flow eyebrow.
*/
export function FeatureCard({
eyebrow,
title,
description,
href,
linkLabel,
backdropSrc,
mediaSide = 'left',
flushBottom = false,
children,
}: FeatureCardProps) {
const mediaRight = mediaSide === 'right'
return (
<article
className={cn(
'relative grid gap-10 rounded-[10px] border border-[var(--border)] p-4 max-lg:grid-cols-1 max-lg:gap-6',
mediaRight ? 'grid-cols-[386px_1fr]' : 'grid-cols-[1fr_386px]',
flushBottom && 'rounded-b-none'
)}
>
<ChipTag
variant='mono'
className={cn('absolute top-4 z-10 max-lg:hidden', mediaRight ? 'left-4' : 'right-4')}
>
{eyebrow}
</ChipTag>
<div
aria-hidden='true'
className={cn(
'relative aspect-[3/2] overflow-hidden rounded-[4px] max-lg:order-1 max-lg:aspect-[4/3]',
!backdropSrc && 'bg-[var(--surface-3)]',
mediaRight && 'lg:order-2'
)}
>
{backdropSrc && (
<Image
src={backdropSrc}
alt=''
fill
sizes='(max-width: 1460px) 70vw, 900px'
className='object-cover'
/>
)}
<div className='absolute inset-0 flex items-center justify-center p-4 [&>*]:max-w-full'>
{children}
</div>
</div>
<div
className={cn(
'flex flex-col justify-center max-lg:order-2 max-lg:pb-2',
mediaRight ? 'pl-4 max-lg:pl-0' : 'pr-4 max-lg:pr-0'
)}
>
<ChipTag variant='mono' className='hidden max-lg:mb-3 max-lg:inline-flex max-lg:self-start'>
{eyebrow}
</ChipTag>
<h3 className='text-balance font-medium text-[22px] text-[var(--text-primary)] leading-[1.3] max-sm:text-[20px]'>
{title}
</h3>
<p className='mt-3 text-pretty text-[15px] text-[var(--text-muted)] leading-[1.6]'>
{description}
</p>
{href && linkLabel && (
<Link
href={href}
className='mt-5 flex items-center gap-1.5 text-[15px] text-[var(--text-body)] transition-colors hover-hover:text-[var(--text-primary)]'
>
{linkLabel}
<ArrowRight className='size-[15px]' />
</Link>
)}
</div>
</article>
)
}
@@ -0,0 +1 @@
export { FeatureCard } from './feature-card'
@@ -0,0 +1,50 @@
import Image from 'next/image'
import { CalloutFrame } from '@/app/(landing)/components/features/components/callout-frame'
/**
* The Integrate beat's callout - the REAL platform Integrations page as one
* floating window: a full capture of the workspace UI (sidebar + Integrations
* tab with the showcase mosaic, search, and Featured sections) taken by
* `exports/readme-banner/capture-integrations-ui.mjs` at the hero shot's card
* geometry (1280x735 @2x), framed by the shared {@link CalloutFrame} so it
* wears the hero platform window's exact chrome (10px radius + layered
* shadow).
*
* The window is oversized (125% of the media stage, ~82% of the capture's
* native scale) and anchored with visually EQUAL top and left insets
* (percentage-based - 9.6% of the stage width; 14.4% of its 3:2 height lands
* at the same px), so its top-left corner floats free over the backdrop while
* the right AND bottom edges bleed past the media stage's clip - a zoomed-in
* peek at part of the product rather than a complete miniature, scaling
* proportionally with the aspect-locked stage. Decorative.
*
* `sizes` is derived directly from the section's grid math rather than
* approximated, then rounded up to the worst-case (peak render/viewport
* ratio) in each tier so the browser never under-fetches:
* `callout = 1.25 * (viewport - 2*gutter - 32px card padding - [40px gap +
* 386px fixed copy column, desktop only])`, gutter = `px-20`/`max-lg:px-8`/
* `max-sm:px-5` from `Features`'s grid, matching `FeatureCard`'s
* `max-lg:grid-cols-1` stack. Peak ratios (verified against a static
* reproduction of this exact layout rendered at each Tailwind breakpoint):
* ~113.3% at the `max-width: 1023px` stacked tier's own upper edge, ~108.6%
* at `1460px` (the container's cap, where render width stops growing with
* viewport - hence the final tier is a flat px value, not a vw fraction).
*/
export function IntegrationsCallout() {
return (
<div className='absolute inset-0'>
<CalloutFrame
className='absolute top-[14.4%] left-[9.6%] w-[125%]'
bodyClassName='aspect-[1280/735]'
>
<Image
src='/landing/feature-integrate-ui.png'
alt=''
fill
sizes='(max-width: 1023px) 114vw, (max-width: 1460px) 109vw, 1053px'
className='object-cover'
/>
</CalloutFrame>
</div>
)
}
@@ -0,0 +1,35 @@
import Image from 'next/image'
import { CalloutFrame } from '@/app/(landing)/components/features/components/callout-frame'
/**
* The Context beat's callout - the REAL platform Knowledge base page as one
* floating window: a full capture of the workspace UI (sidebar with Knowledge
* base selected + the knowledge list) taken by
* `exports/readme-banner/capture-knowledge-ui.mjs` at the hero shot's card
* geometry (1280x735 @2x), framed by the shared {@link CalloutFrame} so it
* wears the hero platform window's exact chrome (10px radius + layered
* shadow).
*
* Same oversized treatment as the Integrate card: 125% of the media stage
* with EQUAL top and left insets (96px), so the top-left corner floats free
* over the backdrop while the right and bottom edges bleed past the media
* stage's clip. Decorative.
*/
export function KnowledgeCallout() {
return (
<div className='absolute inset-0'>
<CalloutFrame
className='absolute top-[14.4%] left-[9.6%] w-[125%]'
bodyClassName='aspect-[1280/735]'
>
<Image
src='/landing/feature-context-ui.png'
alt=''
fill
sizes='1050px'
className='object-cover'
/>
</CalloutFrame>
</div>
)
}
@@ -0,0 +1 @@
export { LogsCallout } from './logs-callout'
@@ -0,0 +1,35 @@
import Image from 'next/image'
import { CalloutFrame } from '@/app/(landing)/components/features/components/callout-frame'
/**
* The Monitor beat's callout - the REAL platform Logs page as one floating
* window: a full capture of the workspace UI (sidebar with Logs selected +
* the seeded run table) taken by
* `exports/readme-banner/capture-logs-ui.mjs` at the hero shot's card
* geometry (1280x735 @2x), framed by the shared {@link CalloutFrame} so it
* wears the hero platform window's exact chrome (10px radius + layered
* shadow).
*
* Same oversized treatment as the Context card: 125% of the media stage with
* EQUAL top and left insets (96px), so the top-left corner floats free over
* the backdrop while the right and bottom edges bleed past the media stage's
* clip. Decorative.
*/
export function LogsCallout() {
return (
<div className='absolute inset-0'>
<CalloutFrame
className='absolute top-[14.4%] left-[9.6%] w-[125%]'
bodyClassName='aspect-[1280/735]'
>
<Image
src='/landing/feature-monitor-ui.png'
alt=''
fill
sizes='1050px'
className='object-cover'
/>
</CalloutFrame>
</div>
)
}
@@ -0,0 +1,94 @@
import { BuildCallout } from '@/app/(landing)/components/features/components/build-callout/build-callout'
import { FeatureCard } from '@/app/(landing)/components/features/components/feature-card'
import { IntegrationsCallout } from '@/app/(landing)/components/features/components/integrations-callout/integrations-callout'
import { KnowledgeCallout } from '@/app/(landing)/components/features/components/knowledge-callout/knowledge-callout'
import { LogsCallout } from '@/app/(landing)/components/features/components/logs-callout'
/**
* Landing features - how Sim works, as a platform lifecycle. Four beats, in the
* order you actually use Sim: bring your tools in (Integrate), give it data to
* reason over (Context), build the agent logic (Build), then watch it run
* (Monitor). Each beat is a Cursor-style {@link FeatureCard}: one large
* outlined card holding a media stage (backdrop painting + elevated real-UI
* callout) and a copy column, with the media side alternating card to card.
*
* The section's `<h2>` is `sr-only` - each beat carries its own visible `<h3>`,
* so the section heading exists only to anchor the heading hierarchy and give AI
* crawlers an atomic summary.
*
* Inter-section spacing is owned by the `<main>` flex `gap` in `landing.tsx`;
* this section carries no vertical padding. The section itself is FULL-WIDTH so
* its bottom rule can bleed to the browser edges; the card grid inside carries
* the shared gutter (`px-20`) and the `max-w-[1460px]` cap. The last card
* squares its bottom corners (`flushBottom`) and sits exactly on the rule, so
* its outline merges into the full-bleed divider.
*
* The cards stack in a single column at every width on a 112px rhythm
* (matching Cursor's spacing between feature cards). Below `lg` each card
* internally reflows media-over-copy.
*
* Per-beat icons are still abstract placeholders (text eyebrows); distinct
* abstract glyphs land in a later pass.
*/
export function Features() {
return (
<section id='features' aria-labelledby='features-heading' className='relative w-full'>
<h2 id='features-heading' className='sr-only'>
Integrate your tools, give Sim context, build agents, and monitor every run.
</h2>
<div className='mx-auto grid w-full max-w-[1460px] grid-cols-1 gap-28 px-20 max-sm:gap-12 max-sm:px-5 max-lg:px-8'>
{/* Integrate: bring your stack in. */}
<FeatureCard
eyebrow='Integrate'
title='Connect the tools your work runs on.'
description='Plug in 1,000+ integrations like Slack, HubSpot, Salesforce, and Notion, so Sim agents act across the stack you already use.'
href='/integrations'
linkLabel='Explore integrations'
backdropSrc='/landing/feature-integrate-backdrop.jpg'
>
<IntegrationsCallout />
</FeatureCard>
{/* Context: store data semantically. */}
<FeatureCard
eyebrow='Context'
title='Give Sim data it can reason over.'
description='Sim stores your data semantically in tables, files, and knowledge bases your agents read from to ground every answer in your own data.'
backdropSrc='/landing/feature-context-backdrop.jpg'
mediaSide='right'
>
<KnowledgeCallout />
</FeatureCard>
{/* Build: wire agent logic in the visual builder. */}
<FeatureCard
eyebrow='Build'
title='Build agents that solve real problems.'
description='Wire blocks, models, and integrations into agent logic on a visual builder, from one agent to many working in parallel.'
href='/workflows'
linkLabel='Explore the workflow builder'
>
<BuildCallout />
</FeatureCard>
{/* Monitor: watch every run. */}
<FeatureCard
eyebrow='Monitor'
title='Watch every run, end to end.'
description='Trace each run block by block, with full logs and the real cost, so you always know what ran and why.'
backdropSrc='/landing/feature-monitor-backdrop.jpg'
mediaSide='right'
flushBottom
>
<LogsCallout />
</FeatureCard>
</div>
{/* Full-bleed rule the last card's squared bottom edge merges into -
spans the whole browser, past the content cap and gutter (the section
itself is full-width; only the card grid above is capped). */}
<div aria-hidden='true' className='absolute inset-x-0 bottom-0 h-px bg-[var(--border)]' />
</section>
)
}
@@ -0,0 +1 @@
export { Features } from './features'
@@ -0,0 +1,163 @@
import Link from 'next/link'
import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark'
import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
/**
* Landing footer - the site link directory. Re-authored from the prior landing
* footer's structure and link content, but on the platform's light tokens and
* with no cross-import from `(home)`. Fully responsive like the rest of the page
* - desktop is the baseline, scaled down via `max-*` overrides (7→3→2 columns).
* The closing CTA lives in its own {@link Cta} section above; this is purely the
* `<footer>` landmark.
*
* Carries `SiteNavigationElement` schema for crawlable footer nav. A top
* hairline separates it from the page and spans the full viewport width
* (edge-to-edge): the border lives on the full-width `<footer>` landmark while
* an inner container caps and centers the content at the shared
* `max-w-[1460px]` with the same `px-20` gutter as every section above.
*/
const LINK_CLASS =
'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
interface FooterItem {
label: string
href: string
external?: boolean
}
const PRODUCT_LINKS: FooterItem[] = [
{ label: 'Enterprise', href: '/enterprise' },
{ label: 'Mothership', href: 'https://docs.sim.ai/mothership', external: true },
{ label: 'Workflows', href: 'https://docs.sim.ai', external: true },
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
{ label: 'Tables', href: 'https://docs.sim.ai/tables', external: true },
{ label: 'MCP', href: 'https://docs.sim.ai/agents/mcp', external: true },
{ label: 'API', href: 'https://docs.sim.ai/api-reference/getting-started', external: true },
{ label: 'Self Hosting', href: 'https://docs.sim.ai/platform/self-hosting', external: true },
{ label: 'Status', href: 'https://status.sim.ai', external: true },
]
const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Compare', href: '/comparison' },
{ label: 'Careers', href: '/careers' },
{ label: 'Changelog', href: '/changelog' },
{ label: 'Contact', href: '/contact' },
]
/** Top model providers, sourced from the catalog so labels/hrefs never drift. */
const MODEL_LINKS: FooterItem[] = [
{ label: 'All Models', href: '/models' },
...MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map((provider) => ({
label: provider.name,
href: provider.href,
})),
]
const BLOCK_LINKS: FooterItem[] = [
{ label: 'Agent', href: 'https://docs.sim.ai/workflows/blocks/agent', external: true },
{ label: 'Router', href: 'https://docs.sim.ai/workflows/blocks/router', external: true },
{ label: 'Function', href: 'https://docs.sim.ai/workflows/blocks/function', external: true },
{ label: 'Condition', href: 'https://docs.sim.ai/workflows/blocks/condition', external: true },
{ label: 'API Block', href: 'https://docs.sim.ai/workflows/blocks/api', external: true },
{ label: 'Workflow', href: 'https://docs.sim.ai/workflows/blocks/workflow', external: true },
{ label: 'Parallel', href: 'https://docs.sim.ai/workflows/blocks/parallel', external: true },
{ label: 'Guardrails', href: 'https://docs.sim.ai/workflows/blocks/guardrails', external: true },
{ label: 'Evaluator', href: 'https://docs.sim.ai/workflows/blocks/evaluator', external: true },
{ label: 'Loop', href: 'https://docs.sim.ai/workflows/blocks/loop', external: true },
]
const INTEGRATION_LINKS: FooterItem[] = [
{ label: 'All Integrations', href: '/integrations' },
{ label: 'Slack', href: 'https://docs.sim.ai/integrations/slack', external: true },
{ label: 'GitHub', href: 'https://docs.sim.ai/integrations/github', external: true },
{ label: 'Gmail', href: 'https://docs.sim.ai/integrations/gmail', external: true },
{ label: 'Notion', href: 'https://docs.sim.ai/integrations/notion', external: true },
{ label: 'Salesforce', href: 'https://docs.sim.ai/integrations/salesforce', external: true },
{ label: 'Jira', href: '/integrations/jira' },
{ label: 'Linear', href: 'https://docs.sim.ai/integrations/linear', external: true },
{ label: 'Supabase', href: 'https://docs.sim.ai/integrations/supabase', external: true },
{ label: 'Stripe', href: 'https://docs.sim.ai/integrations/stripe', external: true },
]
const SOCIAL_LINKS: FooterItem[] = [
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true },
{
label: 'LinkedIn',
href: 'https://www.linkedin.com/company/simstudioai/',
external: true,
},
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true },
{
label: 'GitHub',
href: 'https://github.com/simstudioai/sim',
external: true,
},
]
const LEGAL_LINKS: FooterItem[] = [
{ label: 'Terms of Service', href: '/terms' },
{ label: 'Privacy Policy', href: '/privacy' },
]
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
return (
<div>
<h3 className='mb-4 text-[var(--text-primary)] text-sm'>{title}</h3>
<div className='flex flex-col gap-2.5'>
{items.map(({ label, href, external }) =>
external ? (
<a
key={label}
href={href}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{label}
</a>
) : (
<Link key={label} href={href} className={LINK_CLASS}>
{label}
</Link>
)
)}
</div>
</div>
)
}
export function Footer() {
return (
<footer className='mt-[120px] w-full border-[var(--border)] border-t max-sm:mt-16 max-lg:mt-[88px]'>
<div className='mx-auto w-full max-w-[1460px] px-20 pt-16 pb-16 max-sm:px-5 max-lg:px-8 max-lg:pt-12 max-lg:pb-12'>
<nav
aria-label='Footer navigation'
itemScope
itemType='https://schema.org/SiteNavigationElement'
className='grid grid-cols-8 gap-x-8 gap-y-10 max-sm:grid-cols-2 max-sm:gap-y-8 max-lg:grid-cols-3'
>
<Link
href='/'
aria-label='Sim home'
className='flex h-[18px] items-center max-lg:col-span-full max-lg:mb-2'
>
<SimWordmark />
</Link>
<FooterColumn title='Product' items={PRODUCT_LINKS} />
<FooterColumn title='Resources' items={RESOURCES_LINKS} />
<FooterColumn title='Blocks' items={BLOCK_LINKS} />
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
<FooterColumn title='Models' items={MODEL_LINKS} />
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
<FooterColumn title='Legal' items={LEGAL_LINKS} />
</nav>
<p className='mt-16 text-[var(--text-muted)] text-sm'>© 2026 Sim. All rights reserved.</p>
</div>
</footer>
)
}
@@ -0,0 +1 @@
export { Footer } from './footer'
@@ -0,0 +1,40 @@
import { ChipLink, cn } from '@sim/emcn'
import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants'
/**
* Hero-scale sizing shared by both CTAs - one step up from the navbar's 30px
* chip: 36px tall, 15px label (the chip's inner span inherits it), horizontal
* padding in `em` at the chip's native 8/14 ratio so it scales with the text.
*/
const CTA_SIZE = 'h-[36px] px-[0.571em] text-[15px] [&>span]:[font-size:inherit]'
/**
* The canonical landing call-to-action - a dark "Request a demo" chip beside an
* outline "Sign up" chip. This is the single source of truth for the CTA used
* by both the landing hero and every platform hero, so the two never drift.
*
* Both CTAs are the same global {@link ChipLink} chrome the navbar's auth
* cluster uses - "Request a demo" is the `primary` (filled) variant like the
* navbar's "Sign up"; "Sign up" here is the default chip with the
* `--border-1` outline like the navbar's "Contact sales" - scaled up one step
* via {@link CTA_SIZE} (the sanctioned size overrides; chrome stays the chip's).
*
* Server Component; owns its own internal spacing. Consumers place it in their
* own stack and pass nothing.
*/
export function HeroCta() {
return (
<div className='flex items-center gap-2 max-sm:w-full max-sm:flex-col max-sm:items-stretch'>
<ChipLink variant='primary' href={DEMO_HREF} className={CTA_SIZE}>
Request a demo
</ChipLink>
<ChipLink
href={SIGNUP_HREF}
prefetch={false}
className={cn(CTA_SIZE, 'border border-[var(--border-1)] max-sm:justify-center')}
>
Sign up
</ChipLink>
</div>
)
}
@@ -0,0 +1 @@
export { HeroCta } from './hero-cta'
@@ -0,0 +1,234 @@
'use client'
import { useEffect, useState } from 'react'
import { cn, Tooltip } from '@sim/emcn'
import {
ArrowRight,
ArrowUp,
Copy,
Mic,
Paperclip,
Plus,
Slash,
ThumbsDown,
ThumbsUp,
} from 'lucide-react'
import { ThinkingLoader } from '@/components/ui'
import { HERO_TOOLTIP_OFFSET } from '@/app/(landing)/components/hero/components/hero-platform-loop/sidebar-hotspots'
/** The conversation the loop plays - mirrors the seeded capture chat. */
const USER_MESSAGE = 'When a new lead signs up, enrich it with company data and post it to #sales.'
const REPLY_MESSAGE =
"On it. I'll build a workflow that enriches each new signup with firmographics, scores it, and posts a summary to your #sales channel in Slack."
const REPLY_WORDS = REPLY_MESSAGE.split(' ')
/** Word-reveal cadence for the streamed reply. */
const STREAM_WORD_MS = 55
/** Follow-up suggestions shown once the reply completes, like the real chat. */
const FOLLOW_UPS = [
'Run a test with a sample lead',
'Deploy the workflow',
'Add lead scoring criteria',
] as const
/** Where the chat pane is within one loop pass. */
export type HeroChatPhase = 'idle' | 'user' | 'thinking' | 'reply'
interface HeroChatLoopProps {
/** Current phase, driven by the parent {@link HeroPlatformLoop} clock. */
phase: HeroChatPhase
/** True during the brief fade-out before the cycle restarts. */
fading: boolean
}
/**
* The Mothership chat pane of the hero's live layer - purely presentational;
* the loop clock lives in `HeroPlatformLoop` so the chat and the workflow
* stage animate off one timeline. Replays one exchange: the user message
* slides in, the goo {@link ThinkingLoader} cycles while the Mothership
* thinks, the reply lands with its action row.
*
* Visuals mirror the real chat pane (`--bg` column, grey user bubble, bare
* reply text, the real composer chrome - 28px round icon buttons, mic + the
* `#808080` send disc on the right cluster) so the seam with the surrounding
* screenshot is invisible. The reply STREAMS in word by word (the way the
* real Mothership streams its responses); once the text completes, the
* "Suggested follow-ups" block (the real special-tags markup: numbered
* `--divider`-ruled rows with a trailing arrow) and the action row land
* together; under `prefers-reduced-motion` it appears whole.
* The content column is centered and capped (like the real full-width
* MothershipChat) so it reads right both full-width (stage collapsed) and at
* half width (stage open). Only the conversation fades on reset - the pane
* and composer are persistent chrome.
*/
export function HeroChatLoop({ phase, fading }: HeroChatLoopProps) {
const showUser = phase !== 'idle'
const showThinking = phase === 'thinking'
const showReply = phase === 'reply'
const [revealedWords, setRevealedWords] = useState(0)
// Stream the reply word by word while the phase holds on 'reply'; any other
// phase (the next cycle's reset) rewinds it for the following pass. The
// count derives from ELAPSED time, not tick count, so throttled background
// tabs catch up in chunks instead of stalling mid-sentence.
useEffect(() => {
if (!showReply) {
setRevealedWords(0)
return
}
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
let interval: ReturnType<typeof setInterval> | null = null
const stream = () => {
const startedAt = performance.now()
interval = setInterval(() => {
const elapsed = performance.now() - startedAt
const n = Math.min(Math.floor(elapsed / STREAM_WORD_MS) + 1, REPLY_WORDS.length)
setRevealedWords(n)
if (n >= REPLY_WORDS.length && interval) clearInterval(interval)
}, STREAM_WORD_MS)
}
// Re-synced on 'change' (not just on mount) so toggling the preference
// mid-stream - e.g. HeroPlatformLoop's showFinished setting phase to
// 'reply' while it's already 'reply' - still snaps the reply to complete
// instead of leaving it mid-word until the running interval catches up.
const syncMotionPreference = () => {
if (interval) clearInterval(interval)
if (media.matches) {
setRevealedWords(REPLY_WORDS.length)
return
}
stream()
}
syncMotionPreference()
media.addEventListener('change', syncMotionPreference)
return () => {
media.removeEventListener('change', syncMotionPreference)
if (interval) clearInterval(interval)
}
}, [showReply])
const replyComplete = revealedWords >= REPLY_WORDS.length
return (
<div className='flex h-full w-full flex-col bg-[var(--bg)]'>
<div
className={cn(
'mx-auto flex min-h-0 w-full max-w-[640px] flex-1 flex-col gap-6 overflow-hidden px-6 pt-6 transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
<div
className={cn(
'max-w-[82%] self-end rounded-2xl bg-[var(--surface-3)] px-4 py-3 text-[15px] text-[var(--text-primary)] leading-[1.5] transition-[opacity,transform] duration-200 ease-out',
showUser ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'
)}
>
{USER_MESSAGE}
</div>
{showThinking && <ThinkingLoader size={26} phase labelRatio={0.58} className='mt-1' />}
<div
className={cn(
'flex flex-col gap-4 transition-opacity duration-200 ease-out',
showReply ? 'opacity-100' : 'opacity-0'
)}
>
<p className='text-[15px] text-[var(--text-primary)] leading-[1.6]'>
{REPLY_WORDS.slice(0, revealedWords).join(' ')}
</p>
<div
className={cn(
'flex flex-col gap-4 transition-opacity duration-200 ease-out',
replyComplete ? 'opacity-100' : 'opacity-0'
)}
>
<div className='flex flex-col'>
<span className='text-[var(--text-body)] text-sm'>Suggested follow-ups</span>
<div className='mt-1.5 flex flex-col'>
{FOLLOW_UPS.map((title, i) => (
<span
key={title}
className={cn(
'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left',
i > 0 && 'border-t'
)}
>
<span className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<span className='text-[var(--text-icon)] text-sm'>{i + 1}</span>
</span>
<span className='flex-1 text-[var(--text-body)] text-sm'>{title}</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
</span>
))}
</div>
</div>
<div className='flex items-center gap-3 text-[var(--text-icon)]'>
<Copy className='size-[14px]' />
<ThumbsUp className='size-[14px]' />
<ThumbsDown className='size-[14px]' />
</div>
</div>
</div>
</div>
<div className='pointer-events-auto mx-auto mb-5 w-[calc(100%-40px)] max-w-[600px] shrink-0 rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2'>
<p className='px-1.5 pt-1 text-[15px] text-[var(--text-muted)]'>Send message to Sim</p>
<div className='mt-2 flex items-center gap-1.5'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label='Add resources'
>
<Plus className='size-[16px] text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Add resources</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label='Attach file'
>
<Paperclip className='size-[16px] text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Attach file</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label='Skills'
>
<Slash className='size-[16px] text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Skills</Tooltip.Content>
</Tooltip.Root>
<span className='ml-auto flex items-center gap-1.5'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='flex size-[28px] items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label='Voice input'
>
<Mic className='size-[16px] text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>Voice input</Tooltip.Content>
</Tooltip.Root>
<span className='flex size-[28px] items-center justify-center rounded-full bg-[#808080]'>
<ArrowUp className='size-[16px] text-white' />
</span>
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,2 @@
export type { HeroChatPhase } from './hero-chat-loop'
export { HeroChatLoop } from './hero-chat-loop'
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
import { HeroStat } from '@/app/(landing)/components/hero/components/hero-stat'
import { HeroCta } from '@/app/(landing)/components/hero-cta'
import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout'
interface LandingHeroHeaderProps {
description: string
eyebrow?: ReactNode
heading: ReactNode
headingId: string
}
/**
* Shared homepage hero header geometry. Marketing routes use this component so
* the headline measure, CTA stack, and right-side global-work stat cannot drift.
*/
export function LandingHeroHeader({
description,
eyebrow,
heading,
headingId,
}: LandingHeroHeaderProps) {
return (
<div className='flex w-full items-end justify-between gap-8'>
<div className='flex min-w-0 flex-1 flex-col items-start gap-[22px] text-left'>
{eyebrow}
<h1
id={headingId}
className='max-w-[1120px] text-balance text-[64px] text-[var(--text-primary)] leading-[1.05] tracking-[-0.01em] max-sm:text-[36px] max-xl:text-[52px] [&>br]:max-sm:hidden'
>
{heading}
</h1>
<p className='w-full min-w-0 max-w-[58ch] text-pretty text-[var(--text-muted)] text-base leading-[1.5]'>
{description}
</p>
<div className={cn('max-sm:w-full', LANDING_HERO_CTA_GAP)}>
<HeroCta />
</div>
</div>
<HeroStat />
</div>
)
}
@@ -0,0 +1 @@
export { LandingHeroHeader } from './hero-header'
@@ -0,0 +1,176 @@
'use client'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import {
HeroChatLoop,
type HeroChatPhase,
} from '@/app/(landing)/components/hero/components/hero-chat-loop'
import { HeroWorkflowStage } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage'
import { SidebarHotspots } from '@/app/(landing)/components/hero/components/hero-platform-loop/sidebar-hotspots'
import { STAGE_BLOCKS } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
/**
* One pass of the synced loop, matching the REAL platform behavior: the chat
* runs FULL-WIDTH (stage collapsed, exactly like `MothershipView`'s `w-0`
* state); the user message lands and the Mothership starts thinking; the stage
* pane SLIDES IN from the right (the real `w-1/2` + `border-l` width
* transition); the workflow assembles block by block inside it; the reply
* lands once the flow is built; the scene holds, fades, and restarts.
*/
const PHASE_STARTS = { user: 500, thinking: 1400 } as const
/** The stage pane starts sliding open here (during thinking). */
const STAGE_OPEN_AT = 1900
/** Block N (build order) pops in at BUILD_START + N * BUILD_STEP. */
const BUILD_START = 2400
const BUILD_STEP = 620
const REPLY_AT = 6400
const TOTAL_MS = 12_500
const RESET_FADE_MS = 260
/**
* The workspace container's interior in the capture's design space (the shot
* is 2560x1470 at 2x, i.e. a 1280x735 CSS layout - oversized vs the 1080x620
* window so the whole UI displays at 84.4%, landing the app's native type at
* cursor.com's ~12.7px demo scale): x 249-1272, y 7-727. The live layer lays
* out at this FIXED size and scales down with the window (`transform: scale`),
* so its text and controls shrink in lockstep with the baked sidebar pixels -
* the "mini app" reading - instead of rendering at 1:1 CSS sizes and looking
* oversized next to the scaled screenshot.
*/
const CHROME_INTERIOR = { width: 1024, height: 721 } as const
/**
* The hero window's live layer - one flex region replaying the REAL Home
* two-pane over the static screenshot. The region covers the workspace
* container's interior (inset a hair inside the shot's baked chrome: the
* horizontal rules 6px from the card top/bottom (the chrome's `p-[8px]` gap is
* tightened to 6px at capture time), the container's left border at ~19.4%,
* and its right border at ~99.4%; the container renders at `6px` radius
* (overridden at capture time from the chrome's 8px so it DISPLAYS at the
* concentric ~4.9px after the 84.4% shot-to-window factor), so the region
* clips itself `rounded-[4px]` to hug the baked corner curves without
* covering them) so every visible outline is the real UI's pixels.
*
* Inside, the layout mirrors `Home`: the {@link HeroChatLoop} is a flex-1
* `--bg` column; the {@link HeroWorkflowStage} pane animates `w-0 ↔ w-1/2`
* with the real `MothershipView` width transition (200ms,
* `cubic-bezier(0.25,0.1,0.25,1)`, `border-l` only while open) - the baked
* chat|stage divider is covered by this region, so the divider users see is
* the live `border-l`, appearing exactly as it does in the product.
*
* Both panes stay `pointer-events-none` (decorative, matching the hero's
* `aria-hidden` frame) - blocks are static. Remounting the stage per cycle
* (`key={cycleId}`) resets build state.
*
* Under `prefers-reduced-motion` the loop never starts: the finished exchange,
* open stage, and fully-built workflow render statically.
*/
export function HeroPlatformLoop() {
const regionRef = useRef<HTMLDivElement>(null)
const [phase, setPhase] = useState<HeroChatPhase>('idle')
const [stageOpen, setStageOpen] = useState(false)
const [builtCount, setBuiltCount] = useState(0)
const [fading, setFading] = useState(false)
const [cycleId, setCycleId] = useState(0)
const [scale, setScale] = useState(1)
// Track the rendered region width and scale the design-space layer to fill
// it, keeping the live layer's proportions locked to the screenshot's.
useLayoutEffect(() => {
const el = regionRef.current
if (!el) return
const measure = () => {
const w = el.getBoundingClientRect().width
if (w > 40) setScale(w / CHROME_INTERIOR.width)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
let timers: ReturnType<typeof setTimeout>[] = []
const clearScheduled = () => {
timers.forEach(clearTimeout)
timers = []
}
const showFinished = () => {
clearScheduled()
setFading(false)
setPhase('reply')
setStageOpen(true)
setBuiltCount(STAGE_BLOCKS.length)
}
const runCycle = () => {
setFading(false)
setPhase('idle')
setStageOpen(false)
setBuiltCount(0)
setCycleId((c) => c + 1)
timers = [
setTimeout(() => setPhase('user'), PHASE_STARTS.user),
setTimeout(() => setPhase('thinking'), PHASE_STARTS.thinking),
setTimeout(() => setStageOpen(true), STAGE_OPEN_AT),
...STAGE_BLOCKS.map((_, i) =>
setTimeout(() => setBuiltCount(i + 1), BUILD_START + i * BUILD_STEP)
),
setTimeout(() => setPhase('reply'), REPLY_AT),
setTimeout(() => setFading(true), TOTAL_MS - RESET_FADE_MS),
setTimeout(runCycle, TOTAL_MS),
]
}
const syncMotionPreference = () => {
clearScheduled()
if (media.matches) {
showFinished()
return
}
runCycle()
}
syncMotionPreference()
media.addEventListener('change', syncMotionPreference)
return () => {
media.removeEventListener('change', syncMotionPreference)
clearScheduled()
}
}, [])
return (
<>
<SidebarHotspots />
<div
ref={regionRef}
className='absolute top-[0.95%] right-[0.55%] bottom-[0.95%] left-[19.45%] overflow-hidden rounded-[4px]'
>
<div
className='flex origin-top-left'
style={{
width: CHROME_INTERIOR.width,
height: CHROME_INTERIOR.height,
transform: `scale(${scale})`,
}}
>
<div className='pointer-events-none relative h-full min-w-0 flex-1'>
<HeroChatLoop phase={phase} fading={fading} />
</div>
<div
className={cn(
'h-full shrink-0 overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
stageOpen ? 'w-1/2 border-l' : 'w-0 min-w-0 border-l-0'
)}
>
<HeroWorkflowStage key={cycleId} builtCount={builtCount} />
</div>
</div>
</div>
</>
)
}
@@ -0,0 +1,164 @@
'use client'
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { StageBlockCard } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-block-card'
import {
handleAnchors,
STAGE_BLOCKS,
STAGE_CANVAS,
STAGE_EDGES,
verticalSmoothStep,
} from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
import {
BLOCK_WIDTH,
type BlockDef,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/** Upper bound on the canvas render scale (the scale at the full 1300px cap). */
const MAX_STAGE_SCALE = 0.71
/** Breathing room between the canvas bounds and the card edges, in card px. */
const STAGE_MARGIN = 20
interface HeroWorkflowStageProps {
/** How many of the stage's blocks (in build order) are on canvas. */
builtCount: number
/** Blocks to stage, in build order. Defaults to the homepage's lead flow. */
blocks?: BlockDef[]
/** Source → target pairs among {@link blocks}. Defaults with them. */
edges?: ReadonlyArray<readonly [string, string]>
/** Design-space bounding box of the block layout. Defaults with them. */
canvas?: { width: number; height: number }
}
/**
* The hero window's live workflow canvas - the right-pane counterpart of the
* chat loop. Blocks pop in one by one as `builtCount` advances (staggered
* scale/fade entrances, edges stroke-draw once both endpoints exist) at their
* fixed positions. The edge SVG is `overflow-visible` - SVGs clip
* at their viewport by default, which would cut the lines if a block ever sat
* outside the design-canvas bounds.
*
* Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
* draggable - `pointer-events-none`, matching the rest of the hero animation.
*
* Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful
* icon-tile + rows card body) in a card shell with vertical-flow handle nubs
* (top in / bottom out), matching the real editor's vertical layout.
*
* The staged flow is injectable (`blocks`/`edges`/`canvas`), defaulting to the
* homepage's lead-enrichment flow - the enterprise loop stages its own flow
* through the same component.
*/
export function HeroWorkflowStage({
builtCount,
blocks = STAGE_BLOCKS,
edges = STAGE_EDGES,
canvas = STAGE_CANVAS,
}: HeroWorkflowStageProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(MAX_STAGE_SCALE)
const blocksById = useMemo(() => new Map(blocks.map((b) => [b.id, b])), [blocks])
// Fit the design canvas to the card: scale down when the pane narrows so the
// branch blocks never clip, capped at the full-width scale. Measures LAYOUT
// size (offsetWidth/Height) - the stage lives inside the platform loop's
// scale-transformed design-space layer, and getBoundingClientRect's visual
// size would compound that outer scale into a double shrink.
useLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const measure = () => {
const w = el.offsetWidth
const h = el.offsetHeight
if (w < 40 || h < 40) return
setScale(
Math.min(
MAX_STAGE_SCALE,
(w - STAGE_MARGIN) / canvas.width,
(h - STAGE_MARGIN) / canvas.height
)
)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [canvas.width, canvas.height])
const builtIds = useMemo(
() => new Set(blocks.slice(0, builtCount).map((b) => b.id)),
[blocks, builtCount]
)
return (
<div
ref={containerRef}
className='flex h-full w-full items-center justify-center overflow-hidden'
>
<div
className='relative shrink-0'
style={{
width: canvas.width * scale,
height: canvas.height * scale,
}}
>
<div
className='absolute top-0 left-0'
style={{
width: canvas.width,
height: canvas.height,
transform: `scale(${scale})`,
transformOrigin: '0 0',
}}
>
<svg
className='pointer-events-none absolute inset-0 overflow-visible'
width={canvas.width}
height={canvas.height}
viewBox={`0 0 ${canvas.width} ${canvas.height}`}
fill='none'
aria-hidden='true'
>
{edges.map(([from, to]) => {
const source = blocksById.get(from)
const target = blocksById.get(to)
if (!source || !target) return null
const visible = builtIds.has(from) && builtIds.has(to)
const s = handleAnchors(source).out
const t = handleAnchors(target).in
return (
<path
key={`${from}-${to}`}
d={verticalSmoothStep(s.x, s.y, t.x, t.y)}
pathLength={1}
stroke='var(--workflow-edge)'
strokeWidth={2}
strokeLinecap='round'
className='transition-[stroke-dashoffset] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] [stroke-dasharray:1]'
style={{ strokeDashoffset: visible ? 0 : 1 } as CSSProperties}
/>
)
})}
</svg>
{blocks.map((block) => {
const built = builtIds.has(block.id)
return (
<div
key={block.id}
className={cn(
'pointer-events-none absolute transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
)}
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
>
<StageBlockCard block={block} />
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { HeroPlatformLoop } from './hero-platform-loop'
@@ -0,0 +1,89 @@
'use client'
import { Tooltip } from '@sim/emcn'
/**
* Cursor-to-bubble gap for tooltips over the mini platform UI - tighter than
* the product-standard 16px so the bubble hugs the cursor proportionately to
* the scaled-down preview.
*/
export const HERO_TOOLTIP_OFFSET = 8
/** Percent-of-image bounds shared by both hotspot kinds. */
interface HotspotBounds {
left: string
top: string
width: string
height: string
}
/** An icon control that shows a tooltip on hover, like the real product. */
interface TooltipHotspot extends HotspotBounds {
label: string
}
/** A sidebar nav row that shows the real row-hover highlight on hover. */
interface RowHotspot extends HotspotBounds {
name: string
}
/**
* Icon controls measured from the capture (2560x1470): the collapse-sidebar
* toggle (x428-468, y36-72) and the Workflows section's "More actions"
* ellipsis (x376-412) and "Create workflow" plus (x424-460) at y920-952.
* Copy matches the real product's tooltips.
*/
const TOOLTIP_HOTSPOTS: TooltipHotspot[] = [
{ label: 'Collapse sidebar', left: '16.72%', top: '2.45%', width: '1.56%', height: '2.45%' },
{ label: 'More actions', left: '14.69%', top: '62.59%', width: '1.4%', height: '2.18%' },
{ label: 'Create workflow', left: '16.56%', top: '62.59%', width: '1.4%', height: '2.18%' },
]
/**
* Workspace nav rows (Tables / Files / Knowledge base), boxed like the real
* sidebar row (`h-[30px] rounded-lg`, text rows measured at y598-616, 662-680,
* 726-750): a 60px-tall (at 2x) highlight box centered on each row's text,
* starting left of the row ICON (icons measured at x37-39, the box carries the
* real row's 8px icon gutter) so the icon sits inside the highlight.
*/
const ROW_HOTSPOTS: RowHotspot[] = [
{ name: 'Tables', left: '0.82%', top: '39.25%', width: '17.15%', height: '4.08%' },
{ name: 'Files', left: '0.82%', top: '43.61%', width: '17.15%', height: '4.08%' },
{ name: 'Knowledge base', left: '0.82%', top: '48.16%', width: '17.15%', height: '4.08%' },
]
/**
* Hover layers over the BAKED sidebar pixels. Icon controls (collapse toggle,
* Workflows more/create) get transparent targets wired to the emcn
* {@link Tooltip} with the product's real copy ({@link HERO_TOOLTIP_OFFSET}
* keeps the bubble close over the mini UI). The Workspace nav rows instead
* reproduce the REAL sidebar row-hover state - `--surface-active` on the
* row's rounded box - via `mix-blend-multiply`, which paints over the baked
* white/text pixels to exactly the result of a background behind the text.
*/
export function SidebarHotspots() {
return (
<div aria-hidden='true' className='pointer-events-none absolute inset-0'>
{TOOLTIP_HOTSPOTS.map((spot) => (
<Tooltip.Root key={spot.label}>
<Tooltip.Trigger asChild>
<span
aria-label={spot.label}
className='pointer-events-auto absolute block'
style={{ left: spot.left, top: spot.top, width: spot.width, height: spot.height }}
/>
</Tooltip.Trigger>
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>{spot.label}</Tooltip.Content>
</Tooltip.Root>
))}
{ROW_HOTSPOTS.map((row) => (
<span
key={row.name}
aria-label={row.name}
className='pointer-events-auto absolute block cursor-pointer rounded-[6px] mix-blend-multiply transition-colors duration-100 hover:bg-[var(--surface-active)]'
style={{ left: row.left, top: row.top, width: row.width, height: row.height }}
/>
))}
</div>
)
}
@@ -0,0 +1,32 @@
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
interface StageBlockCardProps {
block: BlockDef
}
/**
* Block card shell for the vertical-flow stage - the hero-visual's faithful
* {@link WorkflowBlockContent} body with top (incoming) / bottom (outgoing)
* handle nubs instead of the horizontal-flow left/right ones. Shared by the
* hero's live workflow stage and the Build feature card's workflow peek.
*/
export function StageBlockCard({ block }: StageBlockCardProps) {
return (
<div className='relative rounded-[13px] border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm'>
<WorkflowBlockContent block={block} />
{!block.isTrigger && (
<span
aria-hidden
className='-translate-x-1/2 absolute top-[-7px] left-1/2 h-[7px] w-5 rounded-t-[2px] bg-[var(--workflow-edge)]'
/>
)}
{!block.isTerminal && (
<span
aria-hidden
className='-translate-x-1/2 absolute bottom-[-7px] left-1/2 h-[7px] w-5 rounded-b-[2px] bg-[var(--workflow-edge)]'
/>
)}
</div>
)
}
@@ -0,0 +1,129 @@
import { AgentIcon, CodeIcon, SlackIcon, StartIcon, TableIcon } from '@/components/icons'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
import { BLOCK_WIDTH } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/**
* Design-space geometry for the hero's live workflow stage - the lead-enrichment
* flow the chat conversation "builds": Start feeds the enrichment agent, a
* scoring function follows, and the flow fans out to Slack and Tables. Block
* tiles use the platform's grey text ramp (each block a different shade, dark
* enough to carry the white glyph) - color is reserved for REAL third-party
* marks, so only Slack keeps its brand tile (#611F69).
*
* Blocks are ordered by build sequence - the stage reveals `blocks[0..built-1]`
* as the loop's build counter advances, and an edge draws once both its
* endpoints are on canvas.
*/
export const STAGE_BLOCKS: BlockDef[] = [
{
id: 'start',
name: 'Start',
icon: StartIcon,
bgColor: 'var(--text-muted)',
isTrigger: true,
rows: [{ title: 'Inputs', value: '-' }],
x: 155,
y: 12,
},
{
id: 'enrich',
name: 'Enrich lead',
icon: AgentIcon,
bgColor: 'var(--text-primary)',
rows: [
{ title: 'Messages', value: '-' },
{ title: 'Model', value: '-' },
{ title: 'Files', value: '-' },
],
x: 155,
y: 172,
},
{
id: 'score',
name: 'Score fit',
icon: CodeIcon,
bgColor: 'var(--text-secondary)',
rows: [
{ title: 'Code', value: '-' },
{ title: 'Timeout', value: '-' },
],
x: 155,
y: 390,
},
{
id: 'slack',
name: 'Post to #sales',
icon: SlackIcon,
bgColor: '#611F69',
isTerminal: true,
rows: [
{ title: 'Channel', value: '-' },
{ title: 'Message', value: '-' },
],
x: 0,
y: 580,
},
{
id: 'tables',
name: 'Save to Tables',
icon: TableIcon,
bgColor: 'var(--text-body)',
isTerminal: true,
rows: [
{ title: 'Table', value: '-' },
{ title: 'Operation', value: '-' },
],
x: 310,
y: 580,
},
]
/** Source → target pairs, drawn in order as their endpoints land on canvas. */
export const STAGE_EDGES: ReadonlyArray<readonly [string, string]> = [
['start', 'enrich'],
['enrich', 'score'],
['score', 'slack'],
['score', 'tables'],
]
/** Design-space bounding box of the layout above. */
export const STAGE_CANVAS = { width: 560, height: 700 } as const
/**
* Approximate rendered block height - the icon-tile header (~40px) plus the
* rows section (16px padding + 21px per row + 8px gaps). Used to place a
* block's bottom (outgoing) handle; a few px of drift is invisible at stage
* scale.
*/
export function blockHeight(block: BlockDef): number {
const n = block.rows.length
return 40 + (n > 0 ? 16 + n * 21 + (n - 1) * 8 : 0)
}
/**
* Rounded orthogonal ("smoothstep") path for a VERTICAL flow - from a source's
* bottom-center handle to a target's top-center handle, stepping at the
* vertical midpoint with `r`-radius corners. The horizontal-flow counterpart
* lives in `hero-visual/workflow-data.ts`.
*/
export function verticalSmoothStep(sx: number, sy: number, tx: number, ty: number, r = 8): string {
if (Math.abs(tx - sx) < 1) return `M ${sx} ${sy} L ${tx} ${ty}`
const midY = (sy + ty) / 2
const dir = tx >= sx ? 1 : -1
return [
`M ${sx} ${sy}`,
`L ${sx} ${midY - r}`,
`Q ${sx} ${midY} ${sx + dir * r} ${midY}`,
`L ${tx - dir * r} ${midY}`,
`Q ${tx} ${midY} ${tx} ${midY + r}`,
`L ${tx} ${ty}`,
].join(' ')
}
/** Handle anchor points for a block at its fixed position. */
export function handleAnchors(block: BlockDef) {
return {
out: { x: block.x + BLOCK_WIDTH / 2, y: block.y + blockHeight(block) },
in: { x: block.x + BLOCK_WIDTH / 2, y: block.y },
}
}
@@ -0,0 +1,69 @@
/* Plain fade-in for the hero stat number - one smooth opacity ramp. */
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.fadeIn {
animation: fade-in 900ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/*
* Per-character entrance for the placeholder zeros: the leading "0" fades in
* first, then each following character staggers in left to right (70ms per
* character; the 11th lands at 700ms + 500ms fade = fully in by ~1.2s).
*/
.char {
animation: fade-in 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.char:nth-child(2) {
animation-delay: 70ms;
}
.char:nth-child(3) {
animation-delay: 140ms;
}
.char:nth-child(4) {
animation-delay: 210ms;
}
.char:nth-child(5) {
animation-delay: 280ms;
}
.char:nth-child(6) {
animation-delay: 350ms;
}
.char:nth-child(7) {
animation-delay: 420ms;
}
.char:nth-child(8) {
animation-delay: 490ms;
}
.char:nth-child(9) {
animation-delay: 560ms;
}
.char:nth-child(10) {
animation-delay: 630ms;
}
.char:nth-child(11) {
animation-delay: 700ms;
}
@media (prefers-reduced-motion: reduce) {
.fadeIn,
.char {
animation: none !important;
}
}
@@ -0,0 +1,166 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '@sim/emcn'
import styles from '@/app/(landing)/components/hero/components/hero-stat/hero-stat.module.css'
const STAT_VALUE = '0.00000206%'
/** The last-three-digit count-up range: 000 -> 206 (Stripe-style iterate). */
const COUNT_FROM = 0
const COUNT_TO = 206
/** Duration of the count-up once it starts. */
const COUNT_DUR_MS = 1100
/** The LIGHT-GREY placeholder zeros the number staggers in as, char by char. */
const START_CHARS = `0.00000${String(COUNT_FROM).padStart(3, '0')}%`.split('')
/**
* When the per-character stagger has fully landed (last char starts at 700ms
* + its 500ms fade - see hero-stat.module.css).
*/
const CHARS_IN_AT_MS = 1200
/**
* The settle beat starts once the character stagger completes, plus a short
* hold so the number reads before it rises.
*/
const SETTLE_AT_MS = CHARS_IN_AT_MS + 150
/**
* The label reveal waits until the rising number (500ms ease-out) has cleared
* the label's row, so the two never overlap mid-flight.
*/
const REVEAL_AT_MS = SETTLE_AT_MS + 400
/** The dark fill grows in last, once the label's fade-up has finished. */
const FILL_AT_MS = REVEAL_AT_MS + 350
/**
* The hero's right-side stat, with a staggered page-load entrance in four
* beats: the number appears at the bottom of the stat as LIGHT-GREY
* placeholder zeros - the leading "0" fades in first, then each following
* character staggers in left to right - alongside the progress rail (short,
* bottom-anchored, spanning exactly the number's 18px line), the number rises
* into its final slot while the rail GROWS upward at the same rate (same
* duration + easing, so its top edge tracks the number's top), then the
* "Global work done by Sim" label fades up beneath it while the number
* DARKENS to `--text-primary` and the last digits COUNT UP 000 -> 206
* (Stripe-style, ease-out so the ticks slow into the final value), and
* finally the dark progress fill grows up from the rail's foot.
*
* Under `prefers-reduced-motion` everything renders settled immediately (the
* fades are also disabled in the CSS module).
*/
export function HeroStat() {
const [settled, setSettled] = useState(false)
const [revealed, setRevealed] = useState(false)
const [filled, setFilled] = useState(false)
const [count, setCount] = useState(COUNT_FROM)
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
let raf = 0
let settleTimer: ReturnType<typeof setTimeout>
let revealTimer: ReturnType<typeof setTimeout>
let fillTimer: ReturnType<typeof setTimeout>
let countTimer: ReturnType<typeof setTimeout>
const clearScheduled = () => {
clearTimeout(settleTimer)
clearTimeout(revealTimer)
clearTimeout(fillTimer)
clearTimeout(countTimer)
cancelAnimationFrame(raf)
}
const showSettled = () => {
clearScheduled()
setSettled(true)
setRevealed(true)
setFilled(true)
setCount(COUNT_TO)
}
const runEntrance = () => {
setSettled(false)
setRevealed(false)
setFilled(false)
setCount(COUNT_FROM)
settleTimer = setTimeout(() => setSettled(true), SETTLE_AT_MS)
revealTimer = setTimeout(() => setRevealed(true), REVEAL_AT_MS)
fillTimer = setTimeout(() => setFilled(true), FILL_AT_MS)
countTimer = setTimeout(() => {
const start = performance.now()
const tick = (now: number) => {
const t = Math.min((now - start) / COUNT_DUR_MS, 1)
const eased = 1 - (1 - t) ** 3
setCount(Math.round(COUNT_FROM + (COUNT_TO - COUNT_FROM) * eased))
if (t < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
}, REVEAL_AT_MS)
}
// Re-synced on 'change' - not just on mount - so toggling the preference
// mid-entrance snaps straight to the settled state instead of leaving the
// stagger/count-up to keep running on its own clock.
const syncMotionPreference = () => {
clearScheduled()
if (media.matches) {
showSettled()
return
}
runEntrance()
}
syncMotionPreference()
media.addEventListener('change', syncMotionPreference)
return () => {
media.removeEventListener('change', syncMotionPreference)
clearScheduled()
}
}, [])
return (
<div className='flex w-fit shrink-0 items-stretch gap-3 max-lg:hidden'>
<div aria-hidden='true' className={cn('relative w-[2px]', styles.fadeIn)}>
<div
className={cn(
'absolute inset-x-0 bottom-0 flex flex-col justify-end rounded-full bg-[var(--surface-6)] transition-[top] duration-500',
settled ? 'top-0' : 'top-[calc(100%-18px)]'
)}
>
<div
className={cn(
'w-full rounded-full bg-[var(--text-body)] transition-[height] duration-300',
filled ? 'h-[4px]' : 'h-0'
)}
/>
</div>
</div>
<div className='flex flex-col gap-1.5'>
<p
className={cn(
'text-[18px] tabular-nums leading-none transition-[transform,color] duration-500',
settled ? 'translate-y-0' : 'translate-y-[20px]',
revealed ? 'text-[var(--text-primary)]' : 'text-[var(--surface-7)]'
)}
>
<span className='sr-only'>{STAT_VALUE}</span>
<span aria-hidden='true'>
{revealed
? `0.00000${String(count).padStart(3, '0')}%`
: START_CHARS.map((ch, i) => (
<span key={i} className={styles.char}>
{ch}
</span>
))}
</span>
</p>
<p
className={cn(
'text-[var(--text-muted)] text-sm leading-none transition-[opacity,transform] duration-300',
revealed ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'
)}
>
Global work done by Sim
</p>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { HeroStat } from './hero-stat'
@@ -0,0 +1,40 @@
import { cn } from '@sim/emcn'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
interface BlockHandlesProps {
block: BlockDef
handlesVisible?: boolean
}
/**
* The decorative edge-handle nubs for a block - an inbound nub on the left
* unless the block is a trigger, an outbound nub on the right unless it's
* terminal. Absolutely positioned, so the caller must be a `relative` (or
* otherwise positioned) box of the block's width. Shared so the morphed chat
* card (GitHub, rendered as content-only) can carry the same handles as the
* real {@link WorkflowBlock} satellites.
*/
export function BlockHandles({ block, handlesVisible = true }: BlockHandlesProps) {
return (
<>
{!block.isTrigger && (
<span
aria-hidden
className={cn(
'-translate-y-1/2 absolute top-5 left-[-7px] h-5 w-[7px] rounded-l-[2px] bg-[var(--workflow-edge)] transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
handlesVisible ? 'opacity-100' : 'opacity-0'
)}
/>
)}
{!block.isTerminal && (
<span
aria-hidden
className={cn(
'-translate-y-1/2 absolute top-5 right-[-7px] h-5 w-[7px] rounded-r-[2px] bg-[var(--workflow-edge)] transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
handlesVisible ? 'opacity-100' : 'opacity-0'
)}
/>
)}
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,413 @@
'use client'
import {
type CSSProperties,
type RefObject,
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { cn } from '@sim/emcn'
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
import {
ANSWER_TEXT,
BLOCK_WIDTH,
BLOCKS,
HOME_GREETING,
PROMPT_ATOMS,
type PromptAtom,
SEND_BUBBLE_ENTER_MS,
SEND_BUBBLE_REVEAL_DELAY_MS,
WORKFLOW_FOCUS_SCALE,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/**
* What the Mothership chat is showing right now. The travelling thinking loader
* itself lives at the hero root (so it can outlive these layers and track its
* target through the camera zoom + pan); this stage only lays out the card and
* exposes the send button and reply slot as the loader's ref targets.
*
* - `compose` - greeting + prompt input (the typewriter).
* - `morphing` - the instant after send: the disc morphs into the loader while
* the typed prompt fades. The card holds its compose height (the input stays in
* flow, just fading) so it never reshapes mid-morph - the disc just becomes the
* loader in place.
* - `sending` - the morph is done; the prompt is now a user bubble that animates
* in above the loader, growing the card, while the camera holds zoomed in.
* - `thinking` - conversation layout (user bubble + an empty reply slot whose
* height the loader will occupy), shown as the loader slides/docks there.
* - `answering` - the typed reply fills the reply slot.
* - `block` - the card morphs into the first workflow block (the chat shell
* resizes and its content becomes the block's), handing off to the workflow.
*/
export type HomeMode = 'compose' | 'morphing' | 'sending' | 'thinking' | 'answering' | 'block'
/** The first workflow block, shown inside the card during the morph. */
const FIRST_BLOCK = BLOCKS[0]
/** GitHub block content's natural (unscaled) height in px. */
const GH_CONTENT_HEIGHT = 77
/** The chat card's width (compose/conversation). */
const CHAT_CARD_WIDTH = 460
/**
* Natural height of the compose card on first paint - `py-2` (16) + the input
* row (32) + `gap-2` (8) + the action row (28). Seeds the card so it renders at
* its true height immediately and never transitions/resizes on load; the
* observer keeps it exact thereafter. Must track the content's padding/spacing.
*/
const COMPOSE_CARD_HEIGHT = 84
/**
* Card width/height once morphed into the first block (block content × focus
* scale). Deliberately smaller than the chat card so the morph reads as a
* visible SHRINK + reshape, not a same-size content crossfade.
*/
const BLOCK_CARD_WIDTH = BLOCK_WIDTH * WORKFLOW_FOCUS_SCALE
const BLOCK_CARD_HEIGHT = GH_CONTENT_HEIGHT * WORKFLOW_FOCUS_SCALE
interface StageHomeProps {
/** Which beat of the chat to render. */
mode: HomeMode
/** Prompt atoms the typewriter has revealed (compose mode). */
typedCount: number
/** Characters of the Mothership's reply revealed so far (answering mode). */
answerTypedCount: number
/** The prompt text region - the root cursor targets this for its "click in". */
inputRef: RefObject<HTMLDivElement | null>
/** The send button - the root cursor + travelling loader target this to "send". */
sendRef: RefObject<HTMLDivElement | null>
/** The dock - an invisible spot at the LEFT of the send-button row that the
* loader slides to (same row, so the slide is purely horizontal and the card
* never has to resize for it). */
dockRef: RefObject<HTMLDivElement | null>
/**
* The white card element. The parent measures it and, during the send beat
* (`sending`), drives its height per-frame via the `--hero-card-h` CSS variable
* - in lockstep with the camera pan - so the grow-to-fit-the-bubble can't shake
* the card the way a CSS height transition fighting the rAF loop would.
*/
cardRef: RefObject<HTMLDivElement | null>
/**
* Reveal the greeting headline. Held back until the input is being composed,
* then fades in on the `hero-stage-fade` keyframe, slowed to 1.2s (≈3× the
* scene's stock 420ms) so the headline eases in gently. Its space is always
* reserved, so revealing it never shifts the layout.
*/
showGreeting?: boolean
/** Press the send button (scale it down) - driven by the click beat. */
pressed?: boolean
}
/** Staggered enter for a chat bubble - translateY + opacity + blur, interruptible. */
const ENTER_BASE = 'transition-[opacity,transform,filter] ease-[cubic-bezier(0.2,0,0,1)]'
const enterState = (shown: boolean) =>
shown ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-1.5 opacity-0 blur-[3px]'
const Caret = () => (
<span
className='ml-px inline-block h-[16px] w-px translate-y-[2px] animate-caret-blink bg-[var(--text-primary)]'
aria-hidden='true'
/>
)
/** Renders prompt atoms (plain chars + inline `@mention` icon-chips). */
function PromptAtoms({ atoms }: { atoms: PromptAtom[] }) {
return (
<>
{atoms.map((atom, i) =>
atom.kind === 'char' ? (
<span key={`${i}-${atom.char}`}>{atom.char}</span>
) : (
<span key={`${i}-${atom.label}`}>
<span className='relative'>
<span className='invisible'>@</span>
<atom.icon className='absolute inset-0 m-auto size-[12px] translate-y-[1.25px] text-[var(--text-icon)]' />
</span>
{atom.label}
</span>
)
)}
</>
)
}
/**
* The Mothership home stage - a single white card that morphs adaptively.
*
* Both content layers (the compose input and the conversation) are absolutely
* stacked and TOP-anchored; the card measures whichever is active and animates
* its own height to hug it (`transition-[height]`). This makes the card flexible
* - it grows from the input to the reply, and from the thinking loader to the
* typed answer - with no dead space and no fixed min-height. Top-anchoring keeps
* the user bubble pinned, so the loader's label crossfade never shoves it; the
* card `overflow-hidden`s any transient. Cursor is owned by the parent; this
* stage only exposes the input and send button as ref targets. Decorative.
*/
export function StageHome({
mode,
typedCount,
answerTypedCount,
inputRef,
sendRef,
dockRef,
cardRef,
showGreeting = false,
pressed = false,
}: StageHomeProps) {
const isCompose = mode === 'compose'
const isMorphing = mode === 'morphing'
const isBlock = mode === 'block'
const isAnswering = mode === 'answering'
// The prompt input holds the card's compose height through the disc→loader morph
// (`compose`/`morphing`), then fades to a right-aligned bubble once send lands.
const inputInFlow = isCompose || isMorphing
// The typed prompt is a right-aligned chat bubble from the moment send is hit
// (`sending`) onward.
const showBubble = !isCompose && !isMorphing && !isBlock
// While the bubble first appears (`sending`), the PARENT drives the card height
// per-frame (via `--hero-card-h`) in lockstep with the camera, so the grow can't
// shake. Every other beat lets the card own + CSS-transition its own height.
const growControlled = mode === 'sending'
// Hold the card height steady - no CSS height-transition - through both the
// parent-driven grow (`sending`) AND the loader slide + camera track
// (`thinking`). The slide pans the camera by measuring the dock's live
// position each frame and assumes the card holds its size; transitioning
// height there makes the card resize under that measurement, so it jitters.
const holdCardHeight = growControlled || mode === 'thinking'
const contentRef = useRef<HTMLDivElement>(null)
// Seeded at the natural compose height so the send button is never clipped on
// the very first paint; the observer below keeps it exact from then on.
const [cardHeight, setCardHeight] = useState(COMPOSE_CARD_HEIGHT)
// Hug the chat column: measure its natural height and animate the card to it.
// Guard against a collapsed/unpainted layout (width ~0) measuring a
// wildly-wrapped height and poisoning the card size.
const measure = useCallback(() => {
const el = contentRef.current
if (el && el.offsetWidth > 120) setCardHeight(el.offsetHeight)
}, [])
// Synchronous, pre-paint, on every beat that reshapes the content (prompt typing
// out, input→bubble swap, reply filling in) - so the card grows in lockstep with
// no lag. In block mode the card takes a fixed height (`isBlock` branch on the
// style), so the measured value is simply ignored there.
useLayoutEffect(() => {
measure()
}, [measure, mode, typedCount, answerTypedCount])
// Robust backstop: also re-fit on first-paint layout, font load, or any other
// reflow the beat-driven pass can't see - so the send button is never clipped.
useLayoutEffect(() => {
const el = contentRef.current
if (!el) return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [measure])
const visible = PROMPT_ATOMS.slice(0, typedCount)
const isEmpty = typedCount === 0
const answer = ANSWER_TEXT.slice(0, answerTypedCount)
const bubbleTransitionStyle = {
transitionDelay: mode === 'sending' ? `${SEND_BUBBLE_REVEAL_DELAY_MS}ms` : '0ms',
transitionDuration: `${SEND_BUBBLE_ENTER_MS}ms`,
} satisfies CSSProperties
return (
<div className='flex h-full w-full flex-col items-center justify-center px-10'>
<div className='flex w-full max-w-[460px] flex-col gap-5'>
{/* Greeting - visible while composing, then fades. Its SPACE is held
through the sending/thinking scene so collapsing it never re-centres
the column and shifts the card the camera is locked onto; it only
truly collapses once the reply takes over (answering) or the card
morphs to a block. */}
<div
className={cn(
'overflow-hidden text-center transition-[height,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]',
mode === 'compose'
? 'h-[40px] opacity-100'
: mode === 'sending' || mode === 'thinking'
? 'h-[40px] opacity-0'
: 'h-0 opacity-0'
)}
>
{showGreeting ? (
<p className='text-[30px] text-[var(--text-primary)] leading-[40px] [animation:hero-stage-fade_1200ms_cubic-bezier(0.23,1,0.32,1)_both]'>
{HOME_GREETING}
</p>
) : null}
</div>
{/* White card - the SAME shell throughout: it hugs the active chat content,
then resizes and rounds down to become the first workflow block (the
chat → workflow morph happens on this one continuous element). */}
<div
ref={cardRef}
className={cn(
// The chat input's aesthetic (rounded-2xl, border, soft shadow) is
// kept THROUGHOUT - including once morphed into the workflow block -
// so it stays the same white card, just resized.
'relative mx-auto overflow-hidden rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm',
// While the height is held (the parent-driven send-bubble grow, and the
// loader slide where the card keeps its size), do NOT CSS-transition
// height - a transition would fight the per-frame camera/dock tracking
// and read as jitter. Every other beat eases width/height/transform with
// the shared curve.
holdCardHeight
? 'transition-[width,transform] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)]'
: 'transition-[width,height,transform] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
// Nudge up to cancel the chat column's greeting+gap offset, so the
// card centers exactly where the focused workflow block lands.
isBlock && '-translate-y-[10px]'
)}
style={{
width: isBlock ? BLOCK_CARD_WIDTH : CHAT_CARD_WIDTH,
// During the grow, follow the parent-driven variable (seeded to the
// compose height so there's never a collapsed first frame).
height: isBlock
? BLOCK_CARD_HEIGHT
: growControlled
? `var(--hero-card-h, ${COMPOSE_CARD_HEIGHT}px)`
: cardHeight,
}}
>
{/* Chat content - ONE column: the user message (the typewriter input,
then the sent bubble) over an action row (the send button + the
loader's dock, then the reply). The column stays anchored to the
BOTTOM through the send-bubble grow AND the loader slide (the whole
`holdCardHeight` window): during the grow the card expands UPWARD to
reveal the bubble, and through the slide the bottom edge never moves -
so the dock/send button the camera tracks can't shift as the height
source switches. Re-anchoring there would nudge them a sub-pixel and
the slide's camera would chase it (a visible jitter). Every other beat
is top-anchored (content fills the card, so it reads the same either
way), which keeps the block-morph and the reply grow behaving as before. */}
<div
ref={contentRef}
className={cn(
'absolute inset-x-0 flex flex-col gap-2 px-2.5 py-2 transition-opacity duration-[220ms] ease-[cubic-bezier(0.23,1,0.32,1)]',
holdCardHeight ? 'bottom-0' : 'top-0',
isBlock && 'pointer-events-none opacity-0'
)}
>
{/* Message: the typewriter input while composing + morphing, then it
fades and a right-aligned user bubble animates in once `sending`
begins. The input stays in flow (holding the compose height) right
through the morph, so the card never reshapes as the disc becomes
the loader; the bubble takes over the flow only after, growing the
card in one clean transition. */}
<div className='relative'>
<div
ref={inputRef}
className={cn(
'min-h-[24px] px-1 py-1 font-body text-[15px] text-[var(--text-primary)] leading-[24px] tracking-[-0.015em] transition-opacity duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
isCompose ? 'opacity-100' : 'opacity-0',
!inputInFlow && 'pointer-events-none absolute inset-x-0 top-0'
)}
>
{isEmpty ? (
<span className='font-[380] text-[var(--text-subtle)]'>
Ask Sim to build an agent
</span>
) : (
<>
<PromptAtoms atoms={visible} />
<Caret />
</>
)}
</div>
<div
style={bubbleTransitionStyle}
className={cn(
'ml-auto w-fit max-w-[82%] rounded-2xl bg-[var(--surface-5)] px-3.5 py-2 font-body text-[15px] text-[var(--text-primary)] leading-[22px] tracking-[-0.015em]',
ENTER_BASE,
showBubble
? enterState(true)
: cn('pointer-events-none absolute top-0 right-0', enterState(false))
)}
>
<PromptAtoms atoms={PROMPT_ATOMS} />
</div>
</div>
{/* Action row: the loader's dock (left) and the send button (right).
The dock is the loader's slide target; once answering, the reply
types out from that same left edge as the loader fades, so the
docked loader reads as the start of the reply. */}
<div className='flex items-start justify-between gap-2'>
<div className='min-w-0 flex-1'>
{isAnswering ? (
<p className='px-1 font-body text-[15px] text-[var(--text-primary)] leading-[22px] tracking-[-0.015em]'>
{answer}
<Caret />
</p>
) : (
<div ref={dockRef} aria-hidden='true' className='size-[28px]' />
)}
</div>
<div
ref={sendRef}
aria-hidden='true'
className={cn(
'flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-[opacity,transform,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]',
// Visible only while composing; once send is hit the root loader's
// settled orb takes its place (it stays laid out as the loader's
// measure + slide-from target).
isCompose ? 'opacity-100' : 'opacity-0',
// Subtle interaction: lighten on hover, dip in size on press -
// both for a real cursor and for the animation's click beat.
isCompose && 'hover:bg-[#484848] active:scale-90',
pressed && 'scale-90'
)}
>
{/* Up-arrow that draws itself on (shaft then head) via
stroke-dashoffset - `pathLength={1}` normalizes the dash so
one offset value covers the whole glyph. Retracts as send is
hit so the disc is clean when the loader takes it over. */}
<svg viewBox='0 0 24 24' fill='none' className='size-4' aria-hidden='true'>
<path
d='M12 19V5M5 12l7-7 7 7'
pathLength={1}
stroke='var(--surface-2)'
strokeWidth={2}
strokeLinecap='round'
strokeLinejoin='round'
className={cn(
'transition-[stroke-dashoffset] duration-[520ms] ease-[cubic-bezier(0.23,1,0.32,1)] [stroke-dasharray:1]',
isCompose ? '[stroke-dashoffset:0]' : '[stroke-dashoffset:1]'
)}
/>
</svg>
</div>
</div>
</div>
{/* Block layer: the card has morphed into the first workflow block.
Same content as the workflow stage's focused block, at the focus
scale, so handing off to the canvas is seamless. Top-anchored to
match the workflow block's origin. */}
<div
aria-hidden='true'
className={cn(
'absolute inset-x-0 top-0 transition-opacity duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]',
// Hold off until the chat content (≈220ms fade) is gone, then fade in.
isBlock ? 'opacity-100 [transition-delay:280ms]' : 'pointer-events-none opacity-0'
)}
>
<div
className='relative'
style={{
width: BLOCK_WIDTH,
transform: `scale(${WORKFLOW_FOCUS_SCALE})`,
transformOrigin: 'top left',
}}
>
<WorkflowBlockContent block={FIRST_BLOCK} />
<span
aria-hidden
className='-translate-y-1/2 absolute top-5 right-[-7px] h-5 w-[7px] rounded-r-[2px] bg-[var(--workflow-edge)]'
/>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,187 @@
import type { CSSProperties, RefObject } from 'react'
import { cn } from '@sim/emcn'
import { Upload, X } from 'lucide-react'
import {
GRAPH_EDGES,
GRAPH_NODES,
GRAPH_VIEWBOX,
KB_FILES,
KB_NAME,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/** Which beat of the knowledge-base flow the modal is showing. */
export type KbStage = 'empty' | 'files' | 'embeddings'
interface KnowledgeBasePanelProps {
stage: KbStage
/** The Create button - the root cursor targets this to "create". */
createRef: RefObject<HTMLSpanElement | null>
/**
* `modal` renders the standalone centered modal (its own chrome + entrance);
* `morph` renders the content only, filling its host, so a satellite block can
* morph into it in scene space.
*/
motion?: 'modal' | 'morph'
}
/**
* The knowledge-base create UI - a faithful, decorative replica of the real
* `ChipModal` create flow. First an empty dropzone ("Drop files here"); then
* files drop in from above as if dragged from Finder; then the document area
* becomes an embedding map that builds itself node by node while the footer
* reads "Creating…". The Create button is exposed as a cursor target.
*/
export function KnowledgeBasePanel({
stage,
createRef,
motion = 'modal',
}: KnowledgeBasePanelProps) {
const creating = stage === 'embeddings'
const content = (
<div
className={cn(
'overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]',
motion === 'morph' && 'h-full'
)}
>
<div className='flex items-center justify-between px-4 pt-3 pb-2.5'>
<span className='font-medium text-[15px] text-[var(--text-primary)]'>
Create Knowledge Base
</span>
<X className='size-4 text-[var(--text-muted)]' />
</div>
<div className='flex flex-col gap-4 px-4 pb-4'>
<div className='flex flex-col gap-[9px]'>
<span className='text-[13px] text-[var(--text-muted)]'>Name</span>
<div className='flex h-[30px] items-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 text-[14px] text-[var(--text-body)]'>
{KB_NAME}
</div>
</div>
<div className='flex flex-col gap-[9px]'>
<span className='text-[13px] text-[var(--text-muted)]'>
{creating ? 'Embeddings' : 'Upload Documents'}
</span>
<div className='relative h-[188px]'>
{stage === 'empty' && (
<div className='absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-lg border border-[var(--border-1)] border-dashed bg-[var(--surface-5)] text-center'>
<Upload className='size-5 text-[var(--text-muted)]' />
<span className='text-[var(--text-primary)] text-caption'>Drop files here</span>
<span className='text-[var(--text-tertiary)] text-xs'>PDF, DOCX, TXT, CSV, MD</span>
</div>
)}
{stage === 'files' && (
<div className='absolute inset-0 flex flex-col justify-center gap-2'>
{KB_FILES.map((file, i) => (
<div
key={file.name}
className={cn(
'flex items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] p-2',
'animate-hero-file-drop opacity-0 [animation-delay:var(--drop-delay)] motion-reduce:animate-none motion-reduce:opacity-100'
)}
style={{ '--drop-delay': `${120 + i * 170}ms` } as CSSProperties}
>
<file.icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-body)]'>
{file.name}
</span>
<span className='flex-shrink-0 text-[14px] text-[var(--text-muted)]'>
{file.size}
</span>
</div>
))}
</div>
)}
{creating && (
<div className='absolute inset-0 flex flex-col items-center justify-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-3'>
<svg
className='h-[140px] w-full'
viewBox={`0 0 ${GRAPH_VIEWBOX.width} ${GRAPH_VIEWBOX.height}`}
fill='none'
aria-hidden='true'
>
<title>embedding graph</title>
{GRAPH_EDGES.map(([a, b], i) => {
const from = GRAPH_NODES[a]
const to = GRAPH_NODES[b]
return (
<path
key={`${a}-${b}`}
d={`M ${from.x} ${from.y} L ${to.x} ${to.y}`}
pathLength={1}
className='animate-hero-edge-draw [animation-delay:var(--pop-delay)] [stroke-dasharray:1] [stroke-dashoffset:1] motion-reduce:animate-none motion-reduce:[stroke-dashoffset:0]'
stroke='var(--text-subtle)'
strokeWidth={0.5}
style={{ '--pop-delay': `${i * 45}ms` } as CSSProperties}
/>
)
})}
{GRAPH_NODES.map((node, i) => (
<circle
key={`${node.x}-${node.y}`}
cx={node.x}
cy={node.y}
r={node.hub ? 3.4 : i % 3 === 0 ? 2.4 : 1.9}
className={cn(
'opacity-0 [transform-box:fill-box] [transform-origin:center] motion-reduce:animate-none motion-reduce:opacity-100',
node.hub
? 'animate-[hero-node-pop_440ms_cubic-bezier(0.16,1,0.3,1)_var(--pop-delay)_forwards,hero-graph-pulse_2600ms_ease-in-out_calc(var(--pop-delay)+800ms)_infinite]'
: 'animate-hero-node-pop [animation-delay:var(--pop-delay)]'
)}
fill={
node.hub
? 'var(--text-primary)'
: i % 2 === 0
? 'var(--text-secondary)'
: 'var(--text-muted)'
}
style={{ '--pop-delay': `${300 + i * 40}ms` } as CSSProperties}
/>
))}
</svg>
<span className='text-[var(--text-muted)] text-caption'>
Generating embeddings
</span>
</div>
)}
</div>
</div>
</div>
<div className='flex items-center justify-end gap-2 rounded-b-lg bg-[var(--surface-3)] px-4 pt-2 pb-2'>
<span className='flex h-[30px] items-center rounded-lg px-2 text-[14px] text-[var(--text-body)]'>
Cancel
</span>
<span
ref={createRef}
className='flex h-[30px] items-center rounded-lg bg-[var(--text-primary)] px-2 text-[14px] text-[var(--text-inverse)]'
>
{creating ? 'Creating…' : 'Create'}
</span>
</div>
</div>
)
if (motion === 'morph') {
return (
<div className='h-full w-full animate-hero-kb-content-morph opacity-0 motion-reduce:animate-none motion-reduce:opacity-100'>
{content}
</div>
)
}
return (
<div
className={cn(
'w-full max-w-[420px] rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)]',
'animate-hero-modal-in motion-reduce:animate-none'
)}
>
{content}
</div>
)
}
@@ -0,0 +1,132 @@
'use client'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { WorkflowBlock } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block'
import {
BLOCK_WIDTH,
BLOCKS,
CANVAS,
EDGES,
WORKFLOW_FOCUS_SCALE,
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/** The camera beat: held on the first block, panning out, or settled on the whole flow. */
export type WorkflowCameraStage = 'focus' | 'out' | 'hold'
interface StageWorkflowProps {
stage: WorkflowCameraStage
}
/** First (GitHub) block center in design space - the camera's focus target. */
const FOCUS_CENTER = { x: BLOCK_WIDTH / 2, y: 38 }
/** Whole-canvas center - the overview camera target. */
const CANVAS_CENTER = { x: CANVAS.width / 2, y: CANVAS.height / 2 }
/** Zoomed-in scale while held on the first block (≈ the morphed chat card size). */
const FOCUS_SCALE = WORKFLOW_FOCUS_SCALE
/** Pulled-back scale that fits the whole workflow in the panel. */
const OVERVIEW_SCALE = 0.68
/**
* The workflow stage of the hero visual - a design-space canvas with a moving
* "camera". It opens **focused** on the first block (the chat card has just
* morphed into it), holds while that block's content lands and the first edge
* draws, then the camera **pans + zooms out together** to reveal the whole
* GitHub → Agent → Jira flow (the {@link stage} prop drives this).
*
* The camera is a transform on the design-space canvas, positioned so the focus
* point lands at the panel center: `translate(vpW/2 - cx·s, vpH/2 - cy·s)
* scale(s)` (origin top-left). The panel size is measured; until it is known,
* and on first mount, the transition is suppressed so the opening focus frame
* doesn't animate in from a fallback. Purely decorative - `aria-hidden`.
*/
export function StageWorkflow({ stage }: StageWorkflowProps) {
const viewportRef = useRef<HTMLDivElement>(null)
const [vp, setVp] = useState<{ w: number; h: number } | null>(null)
const [animate, setAnimate] = useState(false)
useLayoutEffect(() => {
const el = viewportRef.current
if (!el) return
const measure = () => {
const r = el.getBoundingClientRect()
// Guard an unpainted/collapsed panel from poisoning the camera math.
if (r.width > 120 && r.height > 120) setVp({ w: r.width, h: r.height })
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
// Enable the camera transition only after the opening focus frame is painted,
// so mounting (and the first measurement) snaps into focus rather than gliding.
useEffect(() => {
if (vp) setAnimate(true)
}, [vp])
const focused = stage === 'focus'
const center = focused ? FOCUS_CENTER : CANVAS_CENTER
const scale = focused ? FOCUS_SCALE : OVERVIEW_SCALE
const transform = vp
? `translate(${vp.w / 2 - center.x * scale}px, ${vp.h / 2 - center.y * scale}px) scale(${scale})`
: `translate(0px, 0px) scale(${OVERVIEW_SCALE})`
return (
<div ref={viewportRef} className='relative h-full w-full overflow-hidden'>
<div
className={cn(
'absolute top-0 left-0',
animate && 'transition-transform duration-[1700ms] ease-[cubic-bezier(0.22,1,0.36,1)]'
)}
style={{
width: CANVAS.width,
height: CANVAS.height,
transform,
transformOrigin: '0 0',
}}
>
<svg
className='absolute inset-0'
width={CANVAS.width}
height={CANVAS.height}
viewBox={`0 0 ${CANVAS.width} ${CANVAS.height}`}
fill='none'
aria-hidden='true'
>
{EDGES.map((edge, i) => (
<path
key={edge.id}
d={edge.d}
pathLength={1}
stroke='var(--workflow-edge)'
strokeWidth={2}
strokeLinecap='round'
className='transition-[stroke-dashoffset] duration-[700ms] ease-[cubic-bezier(0.22,1,0.36,1)] [stroke-dasharray:1]'
// Edges stay undrawn while focused; once the camera pulls out they
// draw in order (the second trails the first) as each target is revealed.
style={
{
strokeDashoffset: focused ? 1 : 0,
transitionDelay: `${i * 700}ms`,
} as CSSProperties
}
/>
))}
</svg>
{BLOCKS.map((block) => (
// The first block is already on screen - the chat card morphed into it,
// and the focused camera lands it pixel-matched here; the rest sit in
// design space and are revealed by the camera pull-out.
<div
key={block.id}
className='absolute'
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
>
<WorkflowBlock block={block} />
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,58 @@
import { cn } from '@sim/emcn'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
interface WorkflowBlockContentProps {
block: BlockDef
}
/**
* The inner content of a workflow block - the icon-tile header and optional
* label → value rows - WITHOUT the card chrome or handle nubs. Split out so the
* chat card can host the exact same content while morphing into the first block
* (the card keeps its own continuous shell; only this content crossfades in).
*/
export function WorkflowBlockContent({ block }: WorkflowBlockContentProps) {
return (
<>
<div
className={cn(
'flex items-center gap-2.5 p-2',
block.rows.length > 0 && 'border-[var(--border-1)] border-b'
)}
>
<div
className={cn(
'flex size-[24px] flex-shrink-0 items-center justify-center rounded-md',
block.tileBorder && 'border border-[var(--border-1)]'
)}
style={{ background: block.bgColor }}
>
{block.tileBorder ? (
<block.icon className='size-[16px]' />
) : (
<block.icon className='size-[16px] text-white' />
)}
</div>
<span className='truncate font-medium text-[16px] text-[var(--text-body)]'>
{block.name}
</span>
</div>
{block.rows.length > 0 && (
<div className='flex flex-col gap-2 p-2'>
{block.rows.map((row) => (
<div key={row.title} className='flex items-center gap-2'>
<span className='flex-shrink-0 text-[14px] text-[var(--text-muted)]'>
{row.title}
</span>
<span className='flex min-w-0 flex-1 items-center justify-end gap-1.5 text-[14px] text-[var(--text-body)]'>
{row.valueIcon && <row.valueIcon className='size-[14px] flex-shrink-0' />}
<span className='truncate'>{row.value}</span>
</span>
</div>
))}
</div>
)}
</>
)
}
@@ -0,0 +1,38 @@
import { cn } from '@sim/emcn'
import { BlockHandles } from '@/app/(landing)/components/hero/components/hero-visual/block-handles'
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
interface WorkflowBlockProps {
block: BlockDef
contentVisible?: boolean
handlesVisible?: boolean
}
/**
* A pure presentational workflow block card, faithful to the real WorkflowBlock:
* a fixed-width card with an icon-tile header and optional label → value rows,
* plus decorative handle nubs on its left and right edges. Stateless and
* client-free - positioning and the rise animation are owned by the parent stage.
* `contentVisible`/`handlesVisible` crossfade the content and handles so a block
* can soften into a shell (used when the Jira block morphs into the KB panel).
*/
export function WorkflowBlock({
block,
contentVisible = true,
handlesVisible = true,
}: WorkflowBlockProps) {
return (
<div className='relative w-[250px] rounded-[13px] border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm'>
<div
className={cn(
'transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
contentVisible ? 'opacity-100' : 'opacity-0'
)}
>
<WorkflowBlockContent block={block} />
</div>
<BlockHandles block={block} handlesVisible={handlesVisible} />
</div>
)
}
@@ -0,0 +1,361 @@
import type { ComponentType, SVGProps } from 'react'
import { AgentIcon, AnthropicIcon, GithubIcon, JiraIcon } from '@/components/icons'
import { CsvIcon, DocxIcon, MarkdownIcon, PdfIcon } from '@/components/icons/document-icons'
/**
* Shared data + geometry for the hero visual - the single source of truth the
* presentational stages render against. No JSX, no client code; pure data so it
* can be imported by both server- and client-side modules.
*
* The workflow is laid out in a fixed "design space" (px). Block positions and
* the SVG edge paths share these coordinates, so the `<svg>` overlay and the
* absolutely-positioned block cards line up exactly. {@link CANVAS} is the
* bounding box of that space; the stage scales it to fit the hero panel.
*/
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
/** A single field row inside a block card (label → value), faithful to the real WorkflowBlock. */
export interface BlockRow {
title: string
value: string
/** Optional provider mark shown left of the value (e.g. Anthropic for a Claude model). */
valueIcon?: IconComponent
}
/** A workflow block in design space. */
export interface BlockDef {
id: string
name: string
icon: IconComponent
/** Icon-tile fill - a brand-faithful color or a platform token (`var(--…)`). */
bgColor: string
/** White icon tiles (e.g. Jira) need a hairline so the mark stays visible. */
tileBorder?: boolean
/** Trigger blocks start the flow, so they render no incoming (left) handle. */
isTrigger?: boolean
/** Terminal blocks end the flow, so they render no outgoing (right) handle. */
isTerminal?: boolean
rows: BlockRow[]
/** Top-left corner in design space. */
x: number
y: number
}
/** Fixed block width, matching the real canvas (`BLOCK_DIMENSIONS.FIXED_WIDTH`). */
export const BLOCK_WIDTH = 250
/**
* Camera zoom while the workflow stage is focused on the first block. Chosen so
* the focused first block lands at the same on-screen width as the chat card
* (`BLOCK_WIDTH * SCALE ≈ 460`), so the chat card morphs straight into it with
* no jump. Shared by the chat stage (its morph target) and the workflow camera.
*/
export const WORKFLOW_FOCUS_SCALE = 1.25
/** Handle vertical offset from a block's top edge (matches the real WorkflowBlock). */
export const HANDLE_Y_OFFSET = 20
/**
* Design-space bounding box the stage scales to fit the panel. Sized to exactly
* bound the blocks below (GitHub/Jira at y=0, Agent's foot at ~y=203), so the
* stage's flex-centering centers the workflow with no stray margin.
*/
export const CANVAS = { width: 850, height: 206 } as const
/**
* GitHub → Agent → Jira. A gentle staircase: GitHub and Jira ride high, the
* Agent dips between them, so the two edges read as a clean down-then-up flow.
* The shape is left-right symmetric (Agent centered at x=425) and its bounding
* box matches {@link CANVAS}, keeping it centered in the panel.
*/
export const BLOCKS: BlockDef[] = [
{
id: 'github',
name: 'GitHub',
icon: GithubIcon,
bgColor: '#181C1E',
isTrigger: true,
rows: [{ title: 'Trigger', value: 'PR opened' }],
x: 0,
y: 0,
},
{
id: 'agent',
name: 'Agent',
icon: AgentIcon,
bgColor: 'var(--brand-accent)',
rows: [
{ title: 'Model', value: 'Claude', valueIcon: AnthropicIcon },
{ title: 'Task', value: 'Review PR' },
],
x: 300,
y: 96,
},
{
id: 'jira',
name: 'Jira',
icon: JiraIcon,
bgColor: '#FFFFFF',
tileBorder: true,
isTerminal: true,
rows: [{ title: 'Action', value: 'Create issue' }],
x: 600,
y: 0,
},
]
/** An ordered source → target connection between two blocks. */
export interface EdgeDef {
id: string
/** SVG path `d` in design space; `pathLength` is normalized to 1 by the renderer. */
d: string
}
/**
* Rounded orthogonal ("smoothstep") path from a source's right handle to a
* target's left handle, stepping at the horizontal midpoint with `r`-radius
* corners. Source/target points are taken at `block.x±width` and
* `block.y + HANDLE_Y_OFFSET`, matching where the handles render.
*/
export function smoothStep(sx: number, sy: number, tx: number, ty: number, r = 8): string {
const midX = (sx + tx) / 2
const dir = ty >= sy ? 1 : -1
return [
`M ${sx} ${sy}`,
`L ${midX - r} ${sy}`,
`Q ${midX} ${sy} ${midX} ${sy + dir * r}`,
`L ${midX} ${ty - dir * r}`,
`Q ${midX} ${ty} ${midX + r} ${ty}`,
`L ${tx} ${ty}`,
].join(' ')
}
function handlePoints(sourceId: string, targetId: string) {
const source = BLOCKS.find((b) => b.id === sourceId)
const target = BLOCKS.find((b) => b.id === targetId)
if (!source || !target) throw new Error(`Unknown block in edge ${sourceId}${targetId}`)
return {
sx: source.x + BLOCK_WIDTH,
sy: source.y + HANDLE_Y_OFFSET,
tx: target.x,
ty: target.y + HANDLE_Y_OFFSET,
}
}
export const EDGES: EdgeDef[] = (
[
['github', 'agent'],
['agent', 'jira'],
] as const
).map(([from, to]) => {
const { sx, sy, tx, ty } = handlePoints(from, to)
return { id: `${from}-${to}`, d: smoothStep(sx, sy, tx, ty) }
})
/**
* Unified hero scene geometry. The chat card is block 1 (GitHub), centered at
* the panel center; the rest of the workflow is placed relative to it, all at
* FOCUS scale (design × {@link WORKFLOW_FOCUS_SCALE}). The whole scene is then
* scaled/translated to the OVERVIEW to reveal the full workflow - so the SAME
* card element is continuously block 1 through the pull-out.
*
* Scene origin is the GitHub block's CENTER (which sits at the panel center).
*/
const GH_CENTER_X = BLOCK_WIDTH / 2
/** GitHub block half-height in design space (its content is ~77px tall). */
const GH_CENTER_Y = 38.5
const toSceneX = (dx: number) => (dx - GH_CENTER_X) * WORKFLOW_FOCUS_SCALE
const toSceneY = (dy: number) => (dy - GH_CENTER_Y) * WORKFLOW_FOCUS_SCALE
/** A satellite block (everything past block 1) placed in scene space. */
export interface SceneBlock {
block: BlockDef
/** Top-left in scene space (origin = panel center), at FOCUS scale. */
left: number
top: number
}
/**
* Block 1 (GitHub) in scene space. It's the morphed chat card - rendered
* content-only and clipped by the card's `overflow-hidden` - so its edge-handle
* nub is drawn separately at this position, matching where a satellite block
* (and its handle) would sit.
*/
export const SCENE_BLOCK1: SceneBlock = {
block: BLOCKS[0],
left: toSceneX(BLOCKS[0].x),
top: toSceneY(BLOCKS[0].y),
}
/** Blocks 2…N, positioned relative to the centered first block. */
export const SCENE_SATELLITES: SceneBlock[] = BLOCKS.slice(1).map((block) => ({
block,
left: toSceneX(block.x),
top: toSceneY(block.y),
}))
/** Edge paths in scene space (same connections as {@link EDGES}). */
export const SCENE_EDGES: EdgeDef[] = (
[
['github', 'agent'],
['agent', 'jira'],
] as const
).map(([from, to]) => {
const { sx, sy, tx, ty } = handlePoints(from, to)
return {
id: `${from}-${to}`,
d: smoothStep(toSceneX(sx), toSceneY(sy), toSceneX(tx), toSceneY(ty), 14),
}
})
/**
* Pull-out transform from FOCUS (block 1 centered, full size) to OVERVIEW (whole
* workflow centered, fit to panel). `SCALE` brings the FOCUS-scale scene down to
* the design overview (1.84 × 0.37 ≈ 0.68); the translate recenters the group -
* it matches the design overview's GitHub offset, so the framing is identical to
* the prior camera overview. Transform-origin is the panel center (block 1's
* center), so FOCUS is the identity transform (no measurement needed).
*/
export const SCENE_OVERVIEW_SCALE = 0.68 / WORKFLOW_FOCUS_SCALE
export const SCENE_OVERVIEW_TRANSLATE = { x: -204, y: -43 } as const
/** Camera scale while tracing the workflow edge-by-edge before the full zoom-out. */
export const SCENE_FOLLOW_SCALE = 0.86
/**
* Intermediate camera stops for the edge-follow pass. These keep the active
* destination block centered enough to read while preserving a little context
* around the incoming connection.
*/
export const SCENE_AGENT_FOCUS_TRANSLATE = { x: -323, y: -126 } as const
export const SCENE_JIRA_FOCUS_TRANSLATE = { x: -645, y: 0 } as const
/**
* The typed prompt, encoded as ordered atoms the typewriter reveals one at a
* time. A `char` atom is a single character; a `mention` atom pops in
* atomically as an inline icon-chip - exactly how the real input renders an
* `@GitHub` / `@Jira` mention.
*/
export type PromptAtom =
| { kind: 'char'; char: string }
| { kind: 'mention'; label: string; icon: IconComponent }
const PROMPT_SEGMENTS: Array<string | { label: string; icon: IconComponent }> = [
'Create me a ',
{ label: 'GitHub', icon: GithubIcon },
' PR review bot that connects to ',
{ label: 'Jira', icon: JiraIcon },
]
export const PROMPT_ATOMS: PromptAtom[] = PROMPT_SEGMENTS.flatMap((seg) =>
typeof seg === 'string'
? [...seg].map((char): PromptAtom => ({ kind: 'char', char }))
: [{ kind: 'mention', label: seg.label, icon: seg.icon } as PromptAtom]
)
/** Greeting shown above the input in the home state (matches the Mothership home). */
export const HOME_GREETING = 'What should we get done?'
/** Total reveal cadence for the typewriter, in ms per atom. */
export const TYPE_MS_PER_ATOM = 45
/**
* The Mothership's reply, typed out after it "thinks" (the cycle loader). Keeps
* the world voice - it dispatches an agent - and previews the workflow it's
* about to build, so the chat answer morphs naturally into the canvas below.
*/
export const ANSWER_TEXT = 'On it, dispatching an agent to review every PR and open a Jira issue.'
/** Reveal cadence for the answer typewriter (faster than a human; the AI types). */
export const ANSWER_MS_PER_CHAR = 18
/** White chat card grow after send, before the sent-message bubble is visible. */
export const SEND_BUBBLE_GROW_MS = 620
/** Delay the grey sent-message reveal until the card grow has visibly settled. */
export const SEND_BUBBLE_REVEAL_DELAY_MS = SEND_BUBBLE_GROW_MS + 260
/** Soft enter for the grey sent-message bubble once the card has room for it. */
export const SEND_BUBBLE_ENTER_MS = 280
/** Full send beat duration: grow, reveal, then a brief hold before loader slide. */
export const SEND_BUBBLE_HOLD_MS = SEND_BUBBLE_REVEAL_DELAY_MS + SEND_BUBBLE_ENTER_MS + 220
/** Knowledge-base name shown pre-filled in the create modal. */
export const KB_NAME = 'Product Docs'
/** A file shown dropping into the knowledge-base create modal. */
export interface KbFile {
name: string
size: string
icon: IconComponent
}
export const KB_FILES: KbFile[] = [
{ name: 'product-spec.pdf', size: '2.4 MB', icon: PdfIcon },
{ name: 'api-reference.md', size: '88 KB', icon: MarkdownIcon },
{ name: 'support-faq.docx', size: '1.1 MB', icon: DocxIcon },
{ name: 'pricing.csv', size: '12 KB', icon: CsvIcon },
]
/** Design-space bounding box for the embedding graph (its own SVG viewBox). */
export const GRAPH_VIEWBOX = { width: 340, height: 150 } as const
/** A node in the embedding graph. Hubs are larger, darker, and gently pulse. */
export interface GraphNode {
x: number
y: number
hub?: boolean
}
/**
* A single connected knowledge-graph laid out organically across the viewBox -
* three hubs with satellites, bridged into one mesh. Hand-placed (deterministic,
* SSR-stable) for a balanced, deliberate look rather than random scatter.
*/
export const GRAPH_NODES: GraphNode[] = [
{ x: 38, y: 66 },
{ x: 74, y: 104 },
{ x: 96, y: 44 },
{ x: 132, y: 82, hub: true },
{ x: 158, y: 40 },
{ x: 168, y: 116 },
{ x: 206, y: 70, hub: true },
{ x: 228, y: 38 },
{ x: 236, y: 112 },
{ x: 268, y: 72 },
{ x: 300, y: 104, hub: true },
{ x: 312, y: 58 },
{ x: 286, y: 40 },
{ x: 54, y: 96 },
{ x: 140, y: 122 },
{ x: 250, y: 96 },
]
/** Edges between {@link GRAPH_NODES} (index pairs) - one connected component. */
export const GRAPH_EDGES: Array<[number, number]> = [
[0, 2],
[0, 13],
[13, 1],
[1, 3],
[2, 3],
[2, 4],
[3, 4],
[3, 5],
[3, 14],
[5, 14],
[4, 6],
[6, 7],
[6, 15],
[6, 8],
[7, 12],
[12, 9],
[9, 15],
[8, 15],
[9, 10],
[8, 10],
[10, 11],
[11, 12],
[9, 11],
]
@@ -0,0 +1,128 @@
import { cn } from '@sim/emcn'
import Image from 'next/image'
import { LandingHeroHeader } from '@/app/(landing)/components/hero/components/hero-header'
import { HeroPlatformLoop } from '@/app/(landing)/components/hero/components/hero-platform-loop'
import {
LANDING_CONTENT_WIDTH,
LANDING_GUTTER,
LANDING_HERO_TOP_PADDING,
} from '@/app/(landing)/components/landing-layout'
import { TrustedBy } from '@/app/(landing)/components/trusted-by'
/**
* Landing hero - the only `<h1>` on the page.
*
* A single stacked flow (no split panels): headline and the sign-up row sit
* left-aligned at the top; below them a full-width media frame
* previews the platform UI; the customer-logo row closes the section centered
* underneath. The section is capped and centered at the shared `max-w-[1460px]`
* (`mx-auto`) with the `px-20 max-lg:px-8 max-sm:px-5` gutter so the headline
* starts on the navbar wordmark's vertical line.
*
* Text blocks stack a uniform 22px apart (`gap-[22px]`); the media frame and
* logo row carry their own larger top margins to read as separate bands.
*
* The sign-up row is the shared {@link HeroCta} - the single source of truth for
* the email-capture bar and the "Book a demo" / "Sign up" chips - reused
* verbatim by every platform and solutions hero so the primary CTA never drifts.
*
* The media frame: the painted landscape backdrop (`hero-backdrop.jpg`,
* rendered via `next/image` `fill` + `object-cover` with `priority` - it is the
* LCP element) behind a white window (a soft three-part shadow stack:
* `0 0 0 1px rgba(0,0,0,0.08)` ring in place of a CSS border, plus
* `0 2px 6px rgba(0,0,0,0.05)` contact and `0 4px 42px rgba(0,0,0,0.06)`
* ambient shadows; no browser toolbar) filled edge to edge by the REAL
* platform UI - a 2x
* screenshot (`hero-platform-ui.png`, 2560x1470: a 1280x735 layout shown in
* the 1080x620 window, so the UI reads at 84.4% - the "mini app" type scale
* cursor.com's demo window uses) of the chat-everywhere two-pane (seeded
* Mothership chat left, staged workflow right) captured from the
* `readme-tour-capture` route via
* `exports/readme-banner/capture-hero-platform.mjs`. The window is
* `rounded-[10px]` - matching cursor.com's demo window - and the shot's
* workspace container renders at the concentric inner radius `4px` (outer
* 10px - 6px gap; overridden at capture time from the chrome's 8px). Only the
* SIDEBAR
* remains visible from the shot: the {@link HeroPlatformLoop} island overlays
* the container interior (full-width chat that stages the workflow pane in,
* replaying the conversation with the goo ThinkingLoader), inset a hair INSIDE
* the shot's own baked outlines so the visible chrome is the real UI's pixels
* - never re-drawn.
* The frame is `1300/720` and the window `1080/620` at `83.08%` width, centered
* - matching cursor.com's hero media proportions, with backdrop showing on all
* four sides. Decorative, `aria-hidden`; the `--surface-3` fill remains as the
* loading fallback under the backdrop.
*
* The headline/CTA column shares its row with the right-aligned
* {@link HeroStat} (the "Global work done by Sim" figure with its vertical
* progress rail and staggered page-load entrance), hidden below `lg` where
* the row has no room.
*
* The shared {@link TrustedBy} block renders in its `row` layout - a centered
* muted label above a single centered row of bare wordmarks.
*
* Carries the sr-only ~50-word product summary for AI citation (CLAUDE.md → GEO).
*/
export function Hero() {
return (
<section
id='hero'
aria-labelledby='hero-heading'
className={cn(
'flex flex-col items-start gap-[22px] text-left',
LANDING_CONTENT_WIDTH,
LANDING_GUTTER,
LANDING_HERO_TOP_PADDING
)}
>
<p className='sr-only'>
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect
1,000+ integrations and every major LLM to create agents that automate real work, visually,
conversationally, or with code. Trusted by over 100,000 builders, SOC2 compliant, and
production-ready for teams of every size.
</p>
<LandingHeroHeader
headingId='hero-heading'
heading={
<>
Sim is your AI workspace <br />
for building agentic workflows.
</>
}
description='The open-source workspace where teams build, deploy, and manage AI agents.'
/>
<div
aria-hidden='true'
className='relative mt-[34px] aspect-[1300/720] w-full overflow-hidden rounded-lg bg-[var(--surface-3)] max-sm:aspect-[4/3]'
>
<Image
src='/landing/hero-backdrop.jpg'
alt=''
fill
priority
fetchPriority='high'
quality={90}
sizes='(max-width: 1460px) 100vw, 1300px'
className='object-cover'
/>
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 flex aspect-[1080/620] w-[83.08%] flex-col overflow-hidden rounded-[10px] bg-[var(--surface-1)] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_6px_0_rgba(0,0,0,0.05),0_4px_42px_0_rgba(0,0,0,0.06)]'>
<div className='relative flex-1'>
<Image
src='/landing/hero-platform-ui.png'
alt=''
fill
priority
sizes='(max-width: 1460px) 83vw, 1080px'
className='object-cover object-left-top'
/>
<HeroPlatformLoop />
</div>
</div>
</div>
<TrustedBy layout='row' className='mt-[42px] w-full max-sm:mt-6' />
</section>
)
}
@@ -0,0 +1 @@
export { Hero } from './hero'
@@ -0,0 +1,145 @@
import { SITE_URL } from '@/lib/core/utils/urls'
import { JsonLd } from '@/app/(landing)/components/json-ld'
/**
* Home-page JSON-LD - the entities specific to `/`: the `WebPage`, its
* `BreadcrumbList`, the product `WebApplication` (`#software`, with offers /
* featureList / reviews), and the `SoftwareSourceCode`.
*
* Rendered only by the landing root (`landing.tsx`), server-side before visible
* content. The site-wide `Organization` / `WebSite` entities live in
* {@link SiteStructuredData} (emitted by the shared layout on every page); the
* nodes here reference them by `@id` (`${SITE_URL}#website` / `#organization`).
*
* Maintenance:
* - Offer prices must match the Pricing component exactly.
* - All claims must also appear as visible text on the page.
* - Do not add `aggregateRating` without real, verifiable review data.
*/
const HOME_JSON_LD = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebPage',
'@id': `${SITE_URL}#webpage`,
url: SITE_URL,
name: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents',
isPartOf: { '@id': `${SITE_URL}#website` },
about: { '@id': `${SITE_URL}#software` },
datePublished: '2024-01-01T00:00:00+00:00',
description:
'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.',
breadcrumb: { '@id': `${SITE_URL}#breadcrumb` },
inLanguage: 'en-US',
speakable: {
'@type': 'SpeakableSpecification',
cssSelector: ['#hero-heading', '[id="hero"] p'],
},
potentialAction: [{ '@type': 'ReadAction', target: [SITE_URL] }],
},
{
'@type': 'BreadcrumbList',
'@id': `${SITE_URL}#breadcrumb`,
itemListElement: [{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL }],
},
{
'@type': 'WebApplication',
'@id': `${SITE_URL}#software`,
url: SITE_URL,
name: 'Sim, The AI Workspace',
description:
'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.',
applicationCategory: 'BusinessApplication',
applicationSubCategory: 'AI Workspace',
operatingSystem: 'Web',
browserRequirements: 'Requires a modern browser with JavaScript enabled',
installUrl: `${SITE_URL}/signup`,
offers: [
{
'@type': 'Offer',
name: 'Community Plan: 1,000 credits included',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
{
'@type': 'Offer',
name: 'Pro Plan: 6,000 credits/month',
price: '25',
priceCurrency: 'USD',
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: '25',
priceCurrency: 'USD',
unitText: 'MONTH',
billingIncrement: 1,
},
availability: 'https://schema.org/InStock',
},
{
'@type': 'Offer',
name: 'Max Plan: 25,000 credits/month',
price: '100',
priceCurrency: 'USD',
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: '100',
priceCurrency: 'USD',
unitText: 'MONTH',
billingIncrement: 1,
},
availability: 'https://schema.org/InStock',
},
],
featureList: [
'AI workspace for teams',
'Mothership: natural language agent creation',
'Visual workflow builder',
'1,000+ integrations',
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
'Knowledge base creation',
'Table creation',
'Document creation',
'API access',
'Custom functions',
'Scheduled workflows',
'Event triggers',
],
review: [
{
'@type': 'Review',
author: { '@type': 'Person', name: 'Hasan Toor' },
reviewBody:
'This startup just dropped the fastest way to build AI agents. This Figma-like canvas to build agents will blow your mind.',
url: 'https://x.com/hasantoxr/status/1912909502036525271',
},
{
'@type': 'Review',
author: { '@type': 'Person', name: 'nizzy' },
reviewBody:
'This is the zapier of agent building. I always believed that building agents and using AI should not be limited to technical people. I think this solves just that.',
url: 'https://x.com/nizzyabi/status/1907864421227180368',
},
{
'@type': 'Review',
author: { '@type': 'Organization', name: 'xyflow' },
reviewBody: 'A very good looking agent workflow builder and open source!',
url: 'https://x.com/xyflowdev/status/1909501499719438670',
},
],
},
{
'@type': 'SoftwareSourceCode',
'@id': `${SITE_URL}#source`,
codeRepository: 'https://github.com/simstudioai/sim',
programmingLanguage: ['TypeScript', 'Python'],
runtimePlatform: 'Node.js',
license: 'https://opensource.org/licenses/Apache-2.0',
isPartOf: { '@id': `${SITE_URL}#software` },
},
],
}
export function HomeStructuredData() {
return <JsonLd data={HOME_JSON_LD} />
}
@@ -0,0 +1 @@
export { HomeStructuredData } from './home-structured-data'
@@ -0,0 +1,39 @@
export { BackLink } from './back-link'
export { ChevronArrow } from './chevron-arrow'
export { ContentAuthorLoading, ContentAuthorPage } from './content-author-page'
export { ContentImage } from './content-image'
export { ContentIndexLoading, ContentIndexPage } from './content-index-page'
export { ContentPostLoading, ContentPostPage } from './content-post-page'
export { ContentTagsLoading, ContentTagsPage } from './content-tags-page'
export { Cta } from './cta/cta'
export { Features } from './features'
export { Footer } from './footer'
export { Hero } from './hero'
export { HomeStructuredData } from './home-structured-data'
export type { JsonLdData } from './json-ld'
export { JsonLd } from './json-ld'
export { LandingShell } from './landing-shell'
export { Lifecycle } from './lifecycle'
export { Lightbox } from './lightbox'
export { LogoShell } from './logo-shell'
export { Mothership } from './mothership/mothership'
export { Navbar } from './navbar'
export type {
PlatformCardConfig,
PlatformCardRowConfig,
PlatformHeroConfig,
PlatformPageConfig,
PlatformPillCta,
} from './platform-page'
export { PlatformPage } from './platform-page'
export { ProductDemo } from './product-demo'
export { ShareButton } from './share-button'
export { SiteStructuredData } from './site-structured-data'
export type {
SolutionsCardConfig,
SolutionsCardRowConfig,
SolutionsHeroConfig,
SolutionsPageConfig,
SolutionsPillCta,
} from './solutions-page'
export { SolutionsPage } from './solutions-page'
@@ -0,0 +1,2 @@
export type { JsonLdData } from './json-ld'
export { JsonLd } from './json-ld'
@@ -0,0 +1,50 @@
import type { ReactElement } from 'react'
/**
* Characters that break out of an inline HTML `<script>` context, mapped to
* their unicode escapes. `<` is the dangerous one (`</script>`); `>` and `&` are
* escaped for completeness, and the JS line/paragraph separators (U+2028/U+2029)
* keep the payload valid inside inline scripts.
*/
const HTML_ESCAPES: Record<string, string> = {
'<': '\\u003c',
'>': '\\u003e',
'&': '\\u0026',
[String.fromCharCode(0x2028)]: '\\u2028',
[String.fromCharCode(0x2029)]: '\\u2029',
}
const UNSAFE_HTML_CHARS = new RegExp(`[<>&${String.fromCharCode(0x2028, 0x2029)}]`, 'g')
/**
* Serialize structured data for an inline `application/ld+json` script. Plain
* `JSON.stringify` does not HTML-escape, so a `</script>` (or stray `<`) in the
* data would break out of the script tag and become an XSS sink. Escaping these
* characters as unicode escapes keeps the JSON valid and semantically identical,
* so crawlers read the exact same graph — SEO output is unchanged.
*/
function serializeJsonLd(data: JsonLdData): string {
return JSON.stringify(data).replace(UNSAFE_HTML_CHARS, (char) => HTML_ESCAPES[char])
}
export type JsonLdData = Record<string, unknown>
interface JsonLdProps {
data: JsonLdData
}
/**
* Server-rendered JSON-LD `<script>`. The single source of truth for emitting
* structured data across the landing surface — every page/section passes its
* schema graph as `data` and this owns the safe serialization. Render it in a
* Server Component before visible content so crawlers and AI answer engines read
* the graph first.
*/
export function JsonLd({ data }: JsonLdProps): ReactElement {
return (
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
)
}
@@ -0,0 +1 @@
export { LandingFAQ } from './landing-faq'
@@ -0,0 +1,90 @@
'use client'
import { useId, useState } from 'react'
import { ChevronDown, cn } from '@sim/emcn'
import { domAnimation, LazyMotion, m } from 'framer-motion'
interface LandingFAQItem {
question: string
answer: string
}
interface LandingFAQProps {
faqs: LandingFAQItem[]
}
/**
* Accordion FAQ for landing pages. Answers stay mounted (collapsed via
* animated height) so non-JS crawlers see the full Q&A text and FAQPage
* JSON-LD always matches visible content.
*/
export function LandingFAQ({ faqs }: LandingFAQProps) {
const baseId = useId()
const [openIndex, setOpenIndex] = useState<number | null>(0)
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
return (
<LazyMotion features={domAnimation}>
<div>
{faqs.map(({ question, answer }, index) => {
const isOpen = openIndex === index
const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
const panelId = `${baseId}-faq-panel-${index}`
return (
<div key={question}>
<div
className={cn(
'h-px w-full bg-[var(--border)]',
index === 0 || !showDivider ? 'invisible' : 'visible'
)}
/>
<h3>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
className='-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--surface-hover)]'
aria-expanded={isOpen}
aria-controls={panelId}
>
<span
className={cn(
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
isOpen
? 'text-[var(--text-primary)]'
: 'text-[var(--text-body)] hover:text-[var(--text-primary)]'
)}
>
{question}
</span>
<ChevronDown
className={cn(
'size-3 shrink-0 text-[var(--text-muted)] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>
</h3>
<m.div
id={panelId}
initial={false}
animate={{ height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className='overflow-hidden'
aria-hidden={!isOpen}
>
<div className='pt-2 pb-4'>
<p className='text-[14px] text-[var(--text-body)] leading-[1.75]'>{answer}</p>
</div>
</m.div>
</div>
)
})}
</div>
</LazyMotion>
)
}
@@ -0,0 +1,19 @@
/** Shared centered width used by the landing navbar and every primary section. */
export const LANDING_CONTENT_WIDTH = 'mx-auto w-full max-w-[1460px]'
/** Shared responsive horizontal gutter used across the landing family. */
export const LANDING_GUTTER = 'px-20 max-sm:px-5 max-lg:px-8'
/** Shared responsive top clearance for the first landing hero beneath the navbar. */
export const LANDING_HERO_TOP_PADDING = 'pt-[112px] max-sm:pt-12 max-xl:pt-20'
/** Shared vertical rhythm between top-level landing sections. */
export const LANDING_SECTION_RHYTHM = 'gap-[120px] max-sm:gap-16 max-lg:gap-[88px]'
/**
* Extra top separation for a hero CTA over the hero stack gap. Headline and
* description are one copy group and keep the tight 22px stack gap; the CTA is
* a separate action group, so its description→CTA gap lands at 34px (22 + 12),
* roughly 1.5× the headline→description gap.
*/
export const LANDING_HERO_CTA_GAP = 'mt-3'
@@ -0,0 +1,99 @@
'use client'
import { useRef } from 'react'
import { ArrowUp, cn, Mic, Paperclip, Slash } from '@sim/emcn'
interface LandingPreviewChatInputProps {
value: string
onChange?: (value: string) => void
onSubmit: () => void
placeholder: string
/** Locks the field (used while the demo auto-types). */
readOnly?: boolean
/** Hides the caret (auto-type has no real cursor). */
caretHidden?: boolean
/** Lifts the field with the home-view shadow (only the initial empty state). */
shadow?: boolean
}
const ICON_BUTTON =
'flex size-[28px] flex-shrink-0 items-center justify-center rounded-full transition-colors hover-hover:bg-[var(--surface-1)]'
/**
* The canonical Mothership chat input - a faithful copy of the workspace
* `UserInput`: a white, `rounded-[17px]` field with the text area on top and a
* control row beneath (attach + skills on the left, mic + send on the right).
* The send button carries the real `SEND_BUTTON` fills (`#383838` active,
* `#808080` disabled). Shared by the home empty state and the docked chat pane
* so both read identically.
*/
export function LandingPreviewChatInput({
value,
onChange,
onSubmit,
placeholder,
readOnly = false,
caretHidden = false,
shadow = false,
}: LandingPreviewChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isEmpty = value.trim().length === 0
return (
<div
onClick={() => textareaRef.current?.focus()}
className={cn(
'cursor-text rounded-[17px] border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2',
shadow && 'shadow-[0_1px_2px_0_rgba(18,18,18,0.05)]'
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
if (!readOnly) onChange?.(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
}}
placeholder={placeholder}
aria-label={placeholder}
rows={1}
readOnly={readOnly}
className='m-0 block max-h-[200px] min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-1 py-1 font-body text-[15px] text-[var(--text-primary)] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[var(--text-muted)] focus-visible:ring-0'
style={{ caretColor: caretHidden ? 'transparent' : 'var(--text-primary)' }}
/>
<div className='mt-1 flex items-center justify-between'>
<div className='flex items-center gap-1'>
<span className={ICON_BUTTON}>
<Paperclip className='size-[16px] text-[var(--text-muted)]' />
</span>
<span className={ICON_BUTTON}>
<Slash className='size-[16px] text-[var(--text-muted)]' />
</span>
</div>
<div className='flex items-center gap-1.5'>
<span className={ICON_BUTTON}>
<Mic className='size-[16px] text-[var(--text-muted)]' />
</span>
<button
type='button'
onClick={onSubmit}
disabled={isEmpty}
aria-label='Send message'
className={cn(
'flex size-[28px] flex-shrink-0 items-center justify-center rounded-full border-0 p-0 transition-colors',
isEmpty ? 'cursor-not-allowed bg-[#808080]' : 'bg-[#383838] hover-hover:bg-[#575757]'
)}
>
<ArrowUp className='size-[16px] text-white' />
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,44 @@
import { BubbleChatDelay, ChevronDown, PanelLeft, X } from '@sim/emcn'
interface LandingPreviewChatTitleBarProps {
/** Chat name shown in the switcher chip. */
chatName: string
/** Renders the close (×) control at the right edge (shown when a resource is staged). */
showClose?: boolean
}
/**
* The chat pane's title bar - a faithful copy of the workspace `ChatTitleBar`:
* the sidebar toggle, the chat-switcher split pill (chat icon + name | chevron),
* and an optional close control. Shared by the docked chat pane and the home
* empty state so the two read identically.
*/
export function LandingPreviewChatTitleBar({
chatName,
showClose = false,
}: LandingPreviewChatTitleBarProps) {
return (
<div className='flex h-[44px] flex-shrink-0 items-center gap-1 border-[var(--border)] border-b px-4'>
<span className='-ml-[9px] flex size-[30px] flex-shrink-0 items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
<PanelLeft className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
</span>
{/* Chat-switcher split pill: icon+name segment and a chevron segment
sliced by a 1px panel-colored divider. */}
<span className='flex h-[30px] min-w-0 flex-shrink items-stretch'>
<span className='flex min-w-0 items-center gap-1.5 rounded-l-lg pr-1 pl-2 transition-colors hover-hover:bg-[var(--surface-active)]'>
<BubbleChatDelay className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>{chatName}</span>
</span>
<span aria-hidden='true' className='w-px self-stretch bg-[var(--surface-2)]' />
<span className='flex items-center rounded-r-lg px-1 transition-colors hover-hover:bg-[var(--surface-active)]'>
<ChevronDown className='h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
</span>
</span>
{showClose && (
<span className='-mr-[9px] ml-auto flex size-[30px] flex-shrink-0 items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
<X className='size-[14px] text-[var(--text-icon)]' />
</span>
)}
</div>
)
}
@@ -0,0 +1,127 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { ThinkingLoader } from '@/components/ui'
import { LandingPreviewChatInput } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-input'
import { LandingPreviewChatTitleBar } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-title-bar'
import type { PreviewChat } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/hooks/use-landing-submit'
interface LandingPreviewChatProps {
/** The scripted exchange to play, or `null` to show only the input. */
chat: PreviewChat | null
/** Name shown in the chat-switcher breadcrumb chip. */
chatName: string
/** Re-runs the reveal timeline whenever it changes (one per staged resource). */
animationKey: number
}
/** Reveal beats for the scripted conversation, in ms from the step start. */
const THINKING_AT = 480
const ASSISTANT_AT = 1280
/** Ordered stages of the scripted reveal (user request -> think -> reply). */
type RevealPhase = 'hidden' | 'user' | 'thinking' | 'assistant'
/**
* The Mothership chat pane - the persistent left column of the "chat everywhere"
* layout. Its title bar carries the chat-switcher breadcrumb (a chat-bubble
* chip + the active chat's name + chevron) exactly like the real workspace, so
* the chat reads as the constant that every staged resource hangs off of.
*
* On each `animationKey` change it replays a short scripted exchange: the user's
* request slides in, Sim "thinks" (the cycle loader), then the reply slides in -
* mirroring the workflow building itself on the staged panel to the right. The
* reveal is plain state + CSS transition (no layout animation) so it composes
* under the preview's root `LazyMotion` without pulling in `domMax`.
*
* The input is live: submitting stores the prompt and routes to `/signup`, so a
* visitor's first message survives the auth hop.
*/
export function LandingPreviewChat({ chat, chatName, animationKey }: LandingPreviewChatProps) {
const submit = useLandingSubmit()
const [value, setValue] = useState('')
const [phase, setPhase] = useState<RevealPhase>('hidden')
const revealRef = useRef<{ key: number; chat: PreviewChat | null }>({ key: animationKey, chat })
/**
* Restart the reveal synchronously when the timeline or staged chat changes, so the
* pane never flashes the previous reply before the effect reruns. The previous
* key/chat live in a ref: updates come from the parent's timer-driven state (never a
* transition/Suspense boundary), so the render can't be discarded between the ref
* write and commit.
*/
if (revealRef.current.key !== animationKey || revealRef.current.chat !== chat) {
revealRef.current = { key: animationKey, chat }
setPhase('hidden')
}
useEffect(() => {
if (!chat) return
const raf = requestAnimationFrame(() => setPhase('user'))
const t1 = setTimeout(() => setPhase('thinking'), THINKING_AT)
const t2 = setTimeout(() => setPhase('assistant'), ASSISTANT_AT)
return () => {
cancelAnimationFrame(raf)
clearTimeout(t1)
clearTimeout(t2)
}
}, [animationKey, chat])
const showUser = phase !== 'hidden'
const showThinking = phase === 'thinking'
const showAssistant = phase === 'assistant'
const isEmpty = value.trim().length === 0
const handleSubmit = () => {
if (isEmpty) return
submit(value)
}
return (
<div className='flex h-full w-[400px] flex-shrink-0 flex-col bg-[var(--surface-2)]'>
<LandingPreviewChatTitleBar chatName={chatName} showClose />
{/* Conversation - bottom-anchored so it rests just above the input. */}
<div className='flex min-h-0 flex-1 flex-col justify-end gap-3 overflow-hidden px-3.5 pt-4 pb-1'>
{chat && (
<>
<div
className={cn(
'max-w-[85%] self-end rounded-2xl bg-[var(--surface-1)] px-3.5 py-2 text-[13.5px] text-[var(--text-primary)] leading-[1.45] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0,0,1)]',
showUser ? 'translate-y-0 opacity-100' : 'translate-y-1.5 opacity-0'
)}
>
{chat.user}
</div>
<div className='min-h-[20px] self-start pr-2'>
{showThinking && <ThinkingLoader size={20} startVariant='corners' />}
{showAssistant && (
<p
className={cn(
'max-w-[92%] text-[13.5px] text-[var(--text-primary)] leading-[1.5] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0,0,1)]',
showAssistant ? 'translate-y-0 opacity-100' : 'translate-y-1.5 opacity-0'
)}
>
{chat.assistant}
</p>
)}
</div>
</>
)}
</div>
{/* Input - live: routes a typed prompt to /signup. */}
<div className='flex-shrink-0 px-3 pt-2 pb-3'>
<LandingPreviewChatInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder='Ask Sim anything…'
/>
</div>
</div>
)
}
@@ -0,0 +1,155 @@
import { File } from '@sim/emcn/icons'
import { DocxIcon, PdfIcon } from '@/components/icons/document-icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { ownerCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/utils'
/** Generic audio/zip icon using basic SVG since no dedicated component exists */
function AudioIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M9 18V5l12-2v13' />
<circle cx='6' cy='18' r='3' />
<circle cx='18' cy='16' r='3' />
</svg>
)
}
function JsonlIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M10 9H8' />
<path d='M16 13H8' />
<path d='M16 17H8' />
</svg>
)
}
function ZipIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M10 6h1' />
<path d='M10 10h1' />
<path d='M10 14h1' />
<path d='M9 18h2v2h-2z' />
</svg>
)
}
const COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'size', header: 'Size' },
{ id: 'type', header: 'Type' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: <PdfIcon className='size-[14px]' />, label: 'Q1 Performance Report.pdf' },
size: { label: '2.4 MB' },
type: { icon: <PdfIcon className='size-[14px]' />, label: 'PDF' },
created: { label: '3 hours ago' },
owner: ownerCell('T', 'Theo L.'),
},
},
{
id: '2',
cells: {
name: { icon: <ZipIcon className='size-[14px]' />, label: 'product-screenshots.zip' },
size: { label: '18.7 MB' },
type: { icon: <ZipIcon className='size-[14px]' />, label: 'ZIP' },
created: { label: '1 day ago' },
owner: ownerCell('A', 'Alex M.'),
},
},
{
id: '3',
cells: {
name: { icon: <JsonlIcon className='size-[14px]' />, label: 'training-dataset.jsonl' },
size: { label: '892 KB' },
type: { icon: <JsonlIcon className='size-[14px]' />, label: 'JSONL' },
created: { label: '3 days ago' },
owner: ownerCell('J', 'Jordan P.'),
},
},
{
id: '4',
cells: {
name: { icon: <PdfIcon className='size-[14px]' />, label: 'brand-guidelines.pdf' },
size: { label: '5.1 MB' },
type: { icon: <PdfIcon className='size-[14px]' />, label: 'PDF' },
created: { label: '1 week ago' },
owner: ownerCell('S', 'Sarah K.'),
},
},
{
id: '5',
cells: {
name: { icon: <AudioIcon className='size-[14px]' />, label: 'customer-interviews.mp3' },
size: { label: '45.2 MB' },
type: { icon: <AudioIcon className='size-[14px]' />, label: 'Audio' },
created: { label: 'March 20th, 2026' },
owner: ownerCell('V', 'Vik M.'),
},
},
{
id: '6',
cells: {
name: { icon: <DocxIcon className='size-[14px]' />, label: 'onboarding-playbook.docx' },
size: { label: '1.1 MB' },
type: { icon: <DocxIcon className='size-[14px]' />, label: 'DOCX' },
created: { label: 'March 14th, 2026' },
owner: ownerCell('S', 'Sarah K.'),
},
},
]
/**
* Static landing preview of the Files workspace page.
*/
export function LandingPreviewFiles() {
return (
<LandingPreviewResource
icon={File}
title='Files'
createLabel='Upload file'
searchPlaceholder='Search files...'
columns={COLUMNS}
rows={ROWS}
/>
)
}
@@ -0,0 +1,458 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { ArrowRight, Blimp, Checkbox, ChevronDown, cn, Shuffle } from '@sim/emcn'
import { TypeBoolean, TypeNumber, TypeText } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import { Mail, MessageSquare, Table } from 'lucide-react'
import { captureClientEvent } from '@/lib/posthog/client'
import { LandingPreviewChatInput } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-input'
import { LandingPreviewChatTitleBar } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-title-bar'
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/hooks/use-landing-submit'
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
/** Static, greyscale suggestion rows mirroring the workspace SuggestedActions. */
const SUGGESTED_ACTIONS = [
{ id: 'crm', label: 'Create a CRM with sample data', icon: Table },
{ id: 'slack', label: 'Summarize my unread Slack messages', icon: MessageSquare },
{ id: 'gmail', label: 'Draft replies to my support emails', icon: Mail },
{ id: 'tracker', label: 'Build a project tracker table', icon: Table },
] as const
const AUTO_PROMPT = 'Analyze our customer leads and identify the top prospects'
const MOCK_RESPONSE =
'I analyzed your **Customer Leads** table and found **3 top prospects** with the highest lead scores:\n\n1. **Carol Davis** (StartupCo), Score: 94\n2. **Frank Lee** (Ventures), Score: 88\n3. **Alice Johnson** (Acme Corp), Score: 87\n\nAll three are qualified leads. Want me to draft outreach emails?'
const HOME_TYPE_MS = 40
const HOME_TYPE_START_MS = 600
const TOOL_CALL_DELAY_MS = 500
const RESPONSE_DELAY_MS = 800
const RESOURCE_PANEL_DELAY_MS = 600
const MINI_TABLE_COLUMNS = [
{ id: 'name', label: 'Name', type: 'text' as const, width: '32%' },
{ id: 'company', label: 'Company', type: 'text' as const, width: '30%' },
{ id: 'score', label: 'Score', type: 'number' as const, width: '18%' },
{ id: 'qualified', label: 'Qualified', type: 'boolean' as const, width: '20%' },
]
const MINI_TABLE_ROWS = [
{ name: 'Alice Johnson', company: 'Acme Corp', score: '87', qualified: 'true' },
{ name: 'Bob Williams', company: 'TechCo', score: '62', qualified: 'false' },
{ name: 'Carol Davis', company: 'StartupCo', score: '94', qualified: 'true' },
{ name: 'Dan Miller', company: 'BigCorp', score: '71', qualified: 'true' },
{ name: 'Eva Chen', company: 'Design IO', score: '45', qualified: 'false' },
{ name: 'Frank Lee', company: 'Ventures', score: '88', qualified: 'true' },
]
const COLUMN_TYPE_ICONS = {
text: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
} as const
interface LandingPreviewHomeProps {
autoType?: boolean
}
type ChatPhase = 'input' | 'sent' | 'tool-call' | 'responding' | 'done'
/**
* Landing preview replica of the workspace Home view.
*
* When `autoType` is true, automatically types a prompt, sends it,
* shows a mothership agent group with tool calls, types a response,
* and opens a resource panel - matching the real workspace chat UI.
*/
export const LandingPreviewHome = memo(function LandingPreviewHome({
autoType = false,
}: LandingPreviewHomeProps) {
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const animatedPlaceholder = useAnimatedPlaceholder()
const [chatPhase, setChatPhase] = useState<ChatPhase>('input')
const [responseTypedLength, setResponseTypedLength] = useState(0)
const [showResourcePanel, setShowResourcePanel] = useState(false)
const [toolsExpanded, setToolsExpanded] = useState(true)
const typeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const responseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
const clearAllTimers = useCallback(() => {
for (const t of timersRef.current) clearTimeout(t)
timersRef.current = []
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
if (responseIntervalRef.current) clearInterval(responseIntervalRef.current)
typeIntervalRef.current = null
responseIntervalRef.current = null
}, [])
useEffect(() => {
if (!autoType) return
setChatPhase('input')
setResponseTypedLength(0)
setShowResourcePanel(false)
setToolsExpanded(true)
setInputValue('')
const t1 = setTimeout(() => {
let idx = 0
typeIntervalRef.current = setInterval(() => {
idx++
setInputValue(AUTO_PROMPT.slice(0, idx))
if (idx >= AUTO_PROMPT.length) {
if (typeIntervalRef.current) clearInterval(typeIntervalRef.current)
typeIntervalRef.current = null
const t2 = setTimeout(() => {
setChatPhase('sent')
const t3 = setTimeout(() => {
setChatPhase('tool-call')
const t4 = setTimeout(() => {
setShowResourcePanel(true)
}, RESOURCE_PANEL_DELAY_MS)
timersRef.current.push(t4)
const t5 = setTimeout(() => {
setToolsExpanded(false)
setChatPhase('responding')
let rIdx = 0
responseIntervalRef.current = setInterval(() => {
rIdx++
setResponseTypedLength(rIdx)
if (rIdx >= MOCK_RESPONSE.length) {
if (responseIntervalRef.current) clearInterval(responseIntervalRef.current)
responseIntervalRef.current = null
setChatPhase('done')
}
}, 8)
}, TOOL_CALL_DELAY_MS + RESPONSE_DELAY_MS)
timersRef.current.push(t5)
}, TOOL_CALL_DELAY_MS)
timersRef.current.push(t3)
}, 400)
timersRef.current.push(t2)
}
}, HOME_TYPE_MS)
}, HOME_TYPE_START_MS)
timersRef.current.push(t1)
return clearAllTimers
}, [autoType, clearAllTimers])
const isEmpty = inputValue.trim().length === 0
const handleSubmit = useCallback(() => {
if (isEmpty) return
captureClientEvent('landing_prompt_submitted', {})
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
if (chatPhase !== 'input') {
const isResponding = chatPhase === 'responding' || chatPhase === 'done'
const showToolCall = chatPhase === 'tool-call' || isResponding
return (
<LazyMotion features={domAnimation}>
<div className='flex h-full flex-col'>
<LandingPreviewChatTitleBar chatName='New chat' />
<div className='flex min-h-0 flex-1 overflow-hidden'>
{/* Chat area - matches mothership-view layout */}
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8'>
<div className='mx-auto max-w-[42rem] space-y-6'>
{/* User message - rounded bubble, right-aligned */}
<m.div
className='flex flex-col items-end gap-[6px] pt-3'
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }}
>
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-active)] px-3.5 py-2'>
<p className='font-body text-[14px] text-[var(--text-primary)] leading-[1.5]'>
{AUTO_PROMPT}
</p>
</div>
</m.div>
{/* Assistant - no bubble, full-width prose */}
<AnimatePresence>
{showToolCall && (
<m.div
className='space-y-2.5'
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }}
>
{/* Agent group header - icon + label + chevron */}
<button
type='button'
onClick={() => setToolsExpanded((p) => !p)}
className='flex cursor-pointer items-center gap-2'
>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
<Blimp className='size-[16px] text-[var(--text-icon)]' />
</div>
<span className='text-[var(--text-body)] text-sm'>Sim</span>
<ChevronDown
className='h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150'
style={{
transform: toolsExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
}}
/>
</button>
{/* Tool call items - collapsible */}
<div
className='grid transition-[grid-template-rows] duration-200 ease-out'
style={{
gridTemplateRows: toolsExpanded ? '1fr' : '0fr',
}}
>
<div className='overflow-hidden'>
<div className='flex flex-col gap-1.5 pt-0.5'>
<ToolCallRow
icon={<Table className='size-[15px] text-[var(--text-muted)]' />}
title='Read Customer Leads'
/>
</div>
</div>
</div>
{/* Response prose - full width, no card */}
{isResponding && (
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
>
<ChatMarkdown
content={MOCK_RESPONSE}
visibleLength={responseTypedLength}
isTyping={chatPhase === 'responding'}
/>
</m.div>
)}
</m.div>
)}
</AnimatePresence>
</div>
</div>
{/* Resource panel - slides in from right */}
<AnimatePresence>
{showResourcePanel && (
<m.div
className='hidden h-full flex-shrink-0 overflow-hidden border-[var(--border-1)] border-l lg:flex'
initial={{ width: 0, opacity: 0 }}
animate={{ width: '55%', opacity: 1 }}
transition={{ duration: 0.35, ease: EASE_OUT }}
>
<MiniTablePanel />
</m.div>
)}
</AnimatePresence>
</div>
</div>
</LazyMotion>
)
}
return (
<LazyMotion features={domAnimation}>
<div className='flex h-full flex-col'>
<LandingPreviewChatTitleBar chatName='New chat' />
<div className='flex min-h-0 flex-1 flex-col items-center justify-center px-6 pb-[8vh]'>
<m.h1
className='mb-7 max-w-[36rem] text-balance text-[30px] text-[var(--text-primary)]'
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT }}
>
What should we get done?
</m.h1>
<m.div
className='w-full max-w-[36rem]'
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1, ease: EASE_OUT }}
>
<LandingPreviewChatInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
placeholder={animatedPlaceholder}
readOnly={autoType}
caretHidden={autoType}
shadow
/>
{/* Suggested actions - mirrors the workspace home, greyscale icons. */}
<div className='mt-7'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-[var(--text-muted)] text-small'>Suggested actions</span>
<ChevronDown className='h-[7px] w-[9px] text-[var(--text-muted)]' />
</div>
<div className='-mr-2 flex items-center gap-1.5 rounded-lg px-2 py-1'>
<span className='-mt-px text-[var(--text-muted)] text-small'>Shuffle</span>
<Shuffle className='size-[16px] flex-shrink-0 text-[var(--text-muted)]' />
</div>
</div>
<div className='mt-2 flex flex-col'>
{SUGGESTED_ACTIONS.map((action, i) => {
const Icon = action.icon
return (
<div
key={action.id}
className={cn(
'flex items-center gap-2 border-[var(--border-1)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-1)]',
i > 0 && 'border-t'
)}
>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-muted)]' />
<span className='flex-1 truncate text-[var(--text-primary)] text-sm'>
{action.label}
</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-muted)]' />
</div>
)
})}
</div>
</div>
</m.div>
</div>
</div>
</LazyMotion>
)
})
/**
* Single tool call row matching the real `ToolCallItem` layout:
* indented icon + display title.
*/
function ToolCallRow({ icon, title }: { icon: React.ReactNode; title: string }) {
return (
<div className='flex items-center gap-[8px] pl-[24px]'>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>{icon}</div>
<span className='text-[13px] text-[var(--text-muted)]'>{title}</span>
</div>
)
}
/**
* Renders chat response as full-width prose with bold markdown
* and progressive reveal for the typing effect.
*/
function ChatMarkdown({
content,
visibleLength,
isTyping,
}: {
content: string
visibleLength: number
isTyping: boolean
}) {
const visible = content.slice(0, visibleLength)
const rendered = visible.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br />')
return (
<div className='font-body text-[14px] text-[var(--text-primary)] leading-[1.6]'>
<span dangerouslySetInnerHTML={{ __html: rendered }} />
{isTyping && (
<m.span
className='inline-block h-[14px] w-[1.5px] translate-y-[2px] bg-[var(--text-primary)]'
animate={{ opacity: [1, 0] }}
transition={{
duration: 0.6,
repeat: Number.POSITIVE_INFINITY,
repeatType: 'reverse',
}}
/>
)}
</div>
)
}
/**
* Mini Customer Leads table panel matching the resource panel pattern.
*/
function MiniTablePanel() {
return (
<div className='flex h-full w-full flex-col bg-[var(--surface-2)]'>
<div className='flex items-center gap-2 border-[var(--border)] border-b px-3 py-2'>
<Table className='size-[14px] text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-primary)] text-sm'>Customer Leads</span>
</div>
<div className='min-h-0 flex-1 overflow-auto'>
<table className='w-full table-fixed border-separate border-spacing-0 text-[12px]'>
<colgroup>
{MINI_TABLE_COLUMNS.map((col) => (
<col key={col.id} style={{ width: col.width }} />
))}
</colgroup>
<thead className='sticky top-0 z-10'>
<tr>
{MINI_TABLE_COLUMNS.map((col) => {
const Icon = COLUMN_TYPE_ICONS[col.type]
return (
<th
key={col.id}
className='border-[var(--border-1)] border-r border-b bg-[var(--surface-1)] p-0 text-left'
>
<div className='flex items-center gap-1 px-2 py-1.5'>
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='font-medium text-[11px] text-[var(--text-primary)]'>
{col.label}
</span>
<ChevronDown className='ml-auto h-[6px] w-[8px] text-[var(--text-muted)]' />
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{MINI_TABLE_ROWS.map((row, i) => (
<m.tr
key={row.name}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: i * 0.04, ease: EASE_OUT }}
>
{MINI_TABLE_COLUMNS.map((col) => {
const val = row[col.id as keyof typeof row]
return (
<td
key={col.id}
className='border-[var(--border-1)] border-r border-b px-2 py-1.5 text-[var(--text-body)]'
>
{col.type === 'boolean' ? (
<div className='flex items-center justify-center'>
<Checkbox
size='sm'
checked={val === 'true'}
className='pointer-events-none'
/>
</div>
) : (
<span className='block truncate'>{val}</span>
)}
</td>
)
})}
</m.tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,120 @@
import { Database } from '@sim/emcn/icons'
import {
AirtableIcon,
AsanaIcon,
ConfluenceIcon,
GoogleDocsIcon,
GoogleDriveIcon,
JiraIcon,
SalesforceIcon,
SlackIcon,
ZendeskIcon,
} from '@/components/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const DB_ICON = <Database className='size-[14px]' />
/** Connector icons keyed by a stable slug so list keys never depend on array index
* or a component name that could be mangled/emptied under minification. */
const CONNECTOR_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
airtable: AirtableIcon,
asana: AsanaIcon,
confluence: ConfluenceIcon,
'google-docs': GoogleDocsIcon,
'google-drive': GoogleDriveIcon,
jira: JiraIcon,
salesforce: SalesforceIcon,
slack: SlackIcon,
zendesk: ZendeskIcon,
}
function connectorIcons(slugs: string[]) {
return {
content: (
<div className='flex items-center gap-1'>
{slugs.map((slug) => {
const Icon = CONNECTOR_ICONS[slug]
return <Icon key={slug} className='size-3.5 flex-shrink-0' />
})}
</div>
),
}
}
const COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents' },
{ id: 'tokens', header: 'Tokens' },
{ id: 'connectors', header: 'Connectors' },
{ id: 'created', header: 'Created' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: DB_ICON, label: 'Product Documentation' },
documents: { label: '847' },
tokens: { label: '1,284,392' },
connectors: connectorIcons(['asana', 'google-docs']),
created: { label: '2 days ago' },
},
},
{
id: '2',
cells: {
name: { icon: DB_ICON, label: 'Customer Support KB' },
documents: { label: '234' },
tokens: { label: '892,104' },
connectors: connectorIcons(['zendesk', 'slack']),
created: { label: '1 week ago' },
},
},
{
id: '3',
cells: {
name: { icon: DB_ICON, label: 'Engineering Wiki' },
documents: { label: '1,203' },
tokens: { label: '2,847,293' },
connectors: connectorIcons(['confluence', 'jira']),
created: { label: 'March 12th, 2026' },
},
},
{
id: '4',
cells: {
name: { icon: DB_ICON, label: 'Marketing Assets' },
documents: { label: '189' },
tokens: { label: '634,821' },
connectors: connectorIcons(['google-drive', 'airtable']),
created: { label: 'March 5th, 2026' },
},
},
{
id: '5',
cells: {
name: { icon: DB_ICON, label: 'Sales Playbook' },
documents: { label: '92' },
tokens: { label: '418,570' },
connectors: connectorIcons(['salesforce']),
created: { label: 'February 28th, 2026' },
},
},
]
export function LandingPreviewKnowledge() {
return (
<LandingPreviewResource
icon={Database}
title='Knowledge Base'
createLabel='New base'
searchPlaceholder='Search knowledge bases...'
columns={COLUMNS}
rows={ROWS}
/>
)
}
@@ -0,0 +1,298 @@
'use client'
import { useMemo, useState } from 'react'
import type { BadgeProps } from '@sim/emcn'
import { ArrowUpDown, Badge, cn, Library, ListFilter, Search } from '@sim/emcn'
import { Download, Workflow } from '@sim/emcn/icons'
interface LogRow {
id: string
workflowName: string
date: string
status: 'completed' | 'error' | 'running'
cost: string
trigger: 'webhook' | 'api' | 'schedule' | 'manual' | 'mcp' | 'chat'
triggerLabel: string
duration: string
}
type BadgeVariant = BadgeProps['variant']
const STATUS_VARIANT: Record<LogRow['status'], BadgeVariant> = {
completed: 'gray-secondary',
error: 'gray',
running: 'gray',
}
const STATUS_LABELS: Record<LogRow['status'], string> = {
completed: 'Completed',
error: 'Error',
running: 'Running',
}
const MOCK_LOGS: LogRow[] = [
{
id: '1',
workflowName: 'Customer Onboarding',
date: 'Apr 1 10:42 AM',
status: 'running',
cost: '-',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '-',
},
{
id: '2',
workflowName: 'Lead Enrichment',
date: 'Apr 1 09:15 AM',
status: 'error',
cost: '1 credit',
trigger: 'api',
triggerLabel: 'API',
duration: '2.7s',
},
{
id: '3',
workflowName: 'Email Campaign',
date: 'Apr 1 08:30 AM',
status: 'completed',
cost: '2 credits',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '0.8s',
},
{
id: '4',
workflowName: 'Data Pipeline',
date: 'Mar 31 10:14 PM',
status: 'completed',
cost: '7 credits',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '4.1s',
},
{
id: '5',
workflowName: 'Invoice Processing',
date: 'Mar 31 08:45 PM',
status: 'completed',
cost: '2 credits',
trigger: 'manual',
triggerLabel: 'Manual',
duration: '0.9s',
},
{
id: '6',
workflowName: 'Support Triage',
date: 'Mar 31 07:22 PM',
status: 'completed',
cost: '3 credits',
trigger: 'api',
triggerLabel: 'API',
duration: '1.6s',
},
{
id: '7',
workflowName: 'Content Moderator',
date: 'Mar 31 06:11 PM',
status: 'error',
cost: '1 credit',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '3.2s',
},
]
type SortKey = 'workflowName' | 'date' | 'status' | 'cost' | 'trigger' | 'duration'
const COL_HEADERS: { key: SortKey; label: string }[] = [
{ key: 'workflowName', label: 'Workflow' },
{ key: 'date', label: 'Date' },
{ key: 'status', label: 'Status' },
{ key: 'cost', label: 'Cost' },
{ key: 'trigger', label: 'Trigger' },
{ key: 'duration', label: 'Duration' },
]
export function LandingPreviewLogs() {
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [activeTab, setActiveTab] = useState<'logs' | 'dashboard'>('logs')
function handleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir('asc')
}
}
const sorted = useMemo(() => {
const q = search.toLowerCase()
const filtered = q
? MOCK_LOGS.filter(
(log) =>
log.workflowName.toLowerCase().includes(q) ||
log.triggerLabel.toLowerCase().includes(q) ||
STATUS_LABELS[log.status].toLowerCase().includes(q)
)
: MOCK_LOGS
if (!sortKey) return filtered
return [...filtered].sort((a, b) => {
const av = sortKey === 'cost' ? a.cost.replace(/\D/g, '') : a[sortKey]
const bv = sortKey === 'cost' ? b.cost.replace(/\D/g, '') : b[sortKey]
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [search, sortKey, sortDir])
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Header - fixed 44px to align with the chat title bar across the split. */}
<div className='flex h-[44px] flex-shrink-0 items-center border-[var(--border)] border-b px-6'>
<div className='flex w-full items-center justify-between'>
<div className='flex items-center gap-3'>
<Library className='size-[14px] text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-body)] text-sm'>Logs</span>
</div>
<div className='flex items-center gap-1'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<Download className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Export
</div>
<button
type='button'
onClick={() => setActiveTab('logs')}
className={cn(
'rounded-md px-2 py-1 text-caption transition-colors',
activeTab === 'logs'
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
: 'text-[var(--text-secondary)]'
)}
>
Logs
</button>
<button
type='button'
onClick={() => setActiveTab('dashboard')}
className={cn(
'rounded-md px-2 py-1 text-caption transition-colors',
activeTab === 'dashboard'
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
: 'text-[var(--text-secondary)]'
)}
>
Dashboard
</button>
</div>
</div>
</div>
{/* Options bar */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-2.5'>
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder='Search logs...'
aria-label='Search logs'
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
<div className='flex items-center gap-1.5'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Filter
</div>
<button
type='button'
onClick={() => handleSort(sortKey ?? 'workflowName')}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
<ArrowUpDown className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Sort
</button>
</div>
</div>
</div>
{/* Table - uses <table> for pixel-perfect column alignment with headers */}
<div className='min-h-0 flex-1 overflow-hidden'>
<table className='w-full table-fixed text-sm'>
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '18%' }} />
</colgroup>
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{COL_HEADERS.map(({ key, label }) => (
<th
key={key}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<button
type='button'
onClick={() => handleSort(key)}
className={cn(
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
sortKey === key ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'
)}
>
{label}
{sortKey === key && <ArrowUpDown className='size-[10px] opacity-60' />}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((log) => (
<tr
key={log.id}
className='h-[44px] cursor-default transition-colors hover-hover:bg-[var(--surface-3)]'
>
<td className='px-6 align-middle'>
<div className='flex items-center gap-2'>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-caption'>
{log.workflowName}
</span>
</div>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.date}
</td>
<td className='px-6 align-middle'>
<Badge variant={STATUS_VARIANT[log.status]} size='sm' dot>
{STATUS_LABELS[log.status]}
</Badge>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.cost}
</td>
<td className='px-6 align-middle'>
<Badge variant='gray-secondary' size='sm'>
{log.triggerLabel}
</Badge>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.duration}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,198 @@
'use client'
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { ArrowUpDown, cn, ListFilter, Plus, Search } from '@sim/emcn'
export interface PreviewColumn {
id: string
header: string
width?: number
}
export interface PreviewCell {
icon?: ReactNode
label?: string
content?: ReactNode
}
export interface PreviewRow {
id: string
cells: Record<string, PreviewCell>
}
interface LandingPreviewResourceProps {
icon: React.ComponentType<{ className?: string }>
title: string
createLabel: string
searchPlaceholder: string
columns: PreviewColumn[]
rows: PreviewRow[]
onRowClick?: (id: string) => void
}
export function LandingPreviewResource({
icon: Icon,
title,
createLabel,
searchPlaceholder,
columns,
rows,
onRowClick,
}: LandingPreviewResourceProps) {
const [search, setSearch] = useState('')
const [sortColId, setSortColId] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
function handleSortClick(colId: string) {
if (sortColId === colId) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortColId(colId)
setSortDir('asc')
}
}
const sorted = useMemo(() => {
const q = search.toLowerCase()
const filtered = q
? rows.filter((row) =>
Object.values(row.cells).some((cell) => cell.label?.toLowerCase().includes(q))
)
: rows
if (!sortColId) return filtered
return [...filtered].sort((a, b) => {
const av = a.cells[sortColId]?.label ?? ''
const bv = b.cells[sortColId]?.label ?? ''
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [rows, search, sortColId, sortDir])
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Header - fixed 44px to align with the chat title bar across the split. */}
<div className='flex h-[44px] flex-shrink-0 items-center border-[var(--border)] border-b px-6'>
<div className='flex w-full items-center justify-between'>
<div className='flex items-center gap-3'>
<Icon className='size-[14px] text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-body)] text-sm'>{title}</span>
</div>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<Plus className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
{createLabel}
</div>
</div>
</div>
{/* Options bar */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-2.5'>
<Search className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
<div className='flex items-center gap-1.5'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Filter
</div>
<button
type='button'
onClick={() => handleSortClick(sortColId ?? columns[0]?.id)}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
<ArrowUpDown className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Sort
</button>
</div>
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-hidden'>
<table className='w-full table-fixed text-sm'>
<colgroup>
{columns.map((col, i) => (
<col
key={col.id}
style={i === 0 ? { minWidth: col.width ?? 200 } : { width: col.width ?? 160 }}
/>
))}
</colgroup>
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{columns.map((col) => (
<th
key={col.id}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<button
type='button'
onClick={() => handleSortClick(col.id)}
className={cn(
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
sortColId === col.id
? 'text-[var(--text-secondary)]'
: 'text-[var(--text-muted)]'
)}
>
{col.header}
{sortColId === col.id && <ArrowUpDown className='size-[10px] opacity-60' />}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr
key={row.id}
onClick={() => onRowClick?.(row.id)}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer'
)}
>
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
{cell?.content ? (
cell.content
) : (
<span
className={cn(
'flex min-w-0 items-center gap-3 font-medium text-sm',
colIdx === 0
? 'text-[var(--text-body)]'
: 'text-[var(--text-secondary)]'
)}
>
{cell?.icon && (
<span className='flex-shrink-0 text-[var(--text-icon)]'>
{cell.icon}
</span>
)}
<span className='truncate'>{cell?.label ?? ''}</span>
</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,13 @@
import type { PreviewCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
/** Builds a preview owner cell: a small circular initial badge next to a name. */
export function ownerCell(initial: string, name: string): PreviewCell {
return {
icon: (
<span className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{initial}
</span>
),
label: name,
}
}
@@ -0,0 +1,88 @@
import { Calendar } from '@sim/emcn/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const CAL_ICON = <Calendar className='size-[14px]' />
const COLUMNS: PreviewColumn[] = [
{ id: 'task', header: 'Task' },
{ id: 'schedule', header: 'Schedule', width: 240 },
{ id: 'nextRun', header: 'Next Run' },
{ id: 'lastRun', header: 'Last Run' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
task: { icon: CAL_ICON, label: 'Sync CRM contacts' },
schedule: { label: 'Recurring, every day at 9:00 AM' },
nextRun: { label: 'Tomorrow' },
lastRun: { label: '2 hours ago' },
},
},
{
id: '2',
cells: {
task: { icon: CAL_ICON, label: 'Generate weekly report' },
schedule: { label: 'Recurring, every Monday at 8:00 AM' },
nextRun: { label: 'In 5 days' },
lastRun: { label: '6 days ago' },
},
},
{
id: '3',
cells: {
task: { icon: CAL_ICON, label: 'Clean up stale files' },
schedule: { label: 'Recurring, every Sunday at midnight' },
nextRun: { label: 'In 2 days' },
lastRun: { label: '6 days ago' },
},
},
{
id: '4',
cells: {
task: { icon: CAL_ICON, label: 'Send performance digest' },
schedule: { label: 'Recurring, every Friday at 5:00 PM' },
nextRun: { label: 'In 3 days' },
lastRun: { label: '3 days ago' },
},
},
{
id: '5',
cells: {
task: { icon: CAL_ICON, label: 'Backup production data' },
schedule: { label: 'Recurring, every 4 hours' },
nextRun: { label: 'In 2 hours' },
lastRun: { label: '2 hours ago' },
},
},
{
id: '6',
cells: {
task: { icon: CAL_ICON, label: 'Scrape competitor pricing' },
schedule: { label: 'Recurring, every Tuesday at 6:00 AM' },
nextRun: { label: 'In 6 days' },
lastRun: { label: '1 week ago' },
},
},
]
/**
* Static landing preview of the Scheduled Tasks workspace page.
*/
export function LandingPreviewScheduledTasks() {
return (
<LandingPreviewResource
icon={Calendar}
title='Scheduled Tasks'
createLabel='New scheduled task'
searchPlaceholder='Search scheduled tasks...'
columns={COLUMNS}
rows={ROWS}
/>
)
}
@@ -0,0 +1,206 @@
'use client'
import { ChevronDown, cn, Home, Library } from '@sim/emcn'
import {
Calendar,
Database,
File,
HelpCircle,
Search,
Settings,
Table,
Workflow,
} from '@sim/emcn/icons'
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
export type SidebarView =
| 'home'
| 'workflow'
| 'tables'
| 'files'
| 'knowledge'
| 'logs'
| 'scheduled-tasks'
interface LandingPreviewSidebarProps {
workflows: PreviewWorkflow[]
activeWorkflowId: string
activeView: SidebarView
onSelectWorkflow: (id: string) => void
onSelectHome: () => void
onSelectNav: (id: SidebarView) => void
}
const WORKSPACE_NAV = [
{ id: 'tables', label: 'Tables', icon: Table },
{ id: 'files', label: 'Files', icon: File },
{ id: 'knowledge', label: 'Knowledge Base', icon: Database },
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
{ id: 'logs', label: 'Logs', icon: Library },
] as const
const FOOTER_NAV = [
{ id: 'help', label: 'Help', icon: HelpCircle },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const
function NavItem({
icon: Icon,
label,
isActive,
onClick,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
label: string
isActive?: boolean
onClick?: () => void
}) {
if (!onClick) {
return (
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[13px] text-[var(--text-body)]' style={{ fontWeight: 450 }}>
{label}
</span>
</div>
)
}
return (
<button
type='button'
onClick={onClick}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isActive && 'bg-[var(--c-active)]'
)}
>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[13px] text-[var(--text-body)]' style={{ fontWeight: 450 }}>
{label}
</span>
</button>
)
}
/**
* Lightweight sidebar replicating the real workspace sidebar layout and sizing.
* Starts from the workspace header (no logo/collapse row).
* Only workflow items are interactive - everything else is pointer-events-none.
*/
export function LandingPreviewSidebar({
workflows,
activeWorkflowId,
activeView,
onSelectWorkflow,
onSelectHome,
onSelectNav,
}: LandingPreviewSidebarProps) {
const isHomeActive = activeView === 'home'
return (
<div
className='flex h-full w-[248px] flex-shrink-0 flex-col bg-[var(--surface-1)] pt-3'
style={{ '--c-active': 'var(--surface-active)' } as React.CSSProperties}
>
{/* Workspace Header */}
<div className='flex-shrink-0 px-2.5'>
<div className='pointer-events-none flex h-[32px] w-full items-center gap-2 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] pr-2 pl-[5px]'>
<div className='flex size-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
<path
d='M1 9C1 4.58 4.58 1 9 1'
stroke='var(--surface-1)'
strokeWidth='1.8'
strokeLinecap='round'
/>
</svg>
</div>
<span className='min-w-0 flex-1 truncate text-left font-medium text-[13px] text-[var(--text-primary)]'>
Superark
</span>
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
</div>
</div>
{/* Top Navigation: Home (interactive), Search (static) */}
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
<button
type='button'
onClick={onSelectHome}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isHomeActive && 'bg-[var(--c-active)]'
)}
>
<Home className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span
className='truncate text-[13px] text-[var(--text-body)]'
style={{ fontWeight: 450 }}
>
Home
</span>
</button>
<NavItem icon={Search} label='Search' />
</div>
{/* Workspace */}
<div className='mt-3.5 flex flex-shrink-0 flex-col'>
<div className='px-4 pb-1.5'>
<div className='text-[12px] text-[var(--text-icon)]'>Workspace</div>
</div>
<div className='flex flex-col gap-0.5 px-2'>
{WORKSPACE_NAV.map((item) => (
<NavItem
key={item.id}
icon={item.icon}
label={item.label}
isActive={activeView === item.id}
onClick={() => onSelectNav(item.id)}
/>
))}
</div>
</div>
{/* Scrollable Tasks + Workflows */}
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-3.5'>
{/* Workflows */}
<div className='flex flex-col'>
<div className='px-4'>
<div className='text-[12px] text-[var(--text-icon)]'>Workflows</div>
</div>
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
{workflows.map((workflow) => {
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
return (
<button
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className={cn(
'mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--surface-active)]',
isActive && 'bg-[var(--surface-active)]'
)}
>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<div
className='min-w-0 flex-1 truncate text-left text-[13px] text-[var(--text-body)]'
style={{ fontWeight: 450 }}
>
{workflow.name}
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Footer */}
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pt-[9px] pb-2'>
{FOOTER_NAV.map((item) => (
<NavItem key={item.id} icon={item.icon} label={item.label} />
))}
</div>
</div>
)
}
@@ -0,0 +1,38 @@
import { X } from '@sim/emcn'
import { PanelRight, Workflow } from 'lucide-react'
interface LandingPreviewStageHeaderProps {
/** The staged resource's display name. */
name: string
}
/**
* The staged-resource header - a faithful copy of the workspace `PanelHeader`
* (44px, `px-4`, `gap-1.5`) that sits above the workflow canvas in the "chat
* everywhere" layout. There is no tab strip and no Deploy/Run: a workflow's
* panel actions are `null` in the real header, so it carries only the staged
* resource's identity - the lucide `Workflow` mark (`size-[14px]`, `--text-icon`)
* and its name in chip geometry - plus the panel's close + collapse controls on
* the right. Aligns to the chat pane's title bar so the two read as one header
* row across the split.
*/
export function LandingPreviewStageHeader({ name }: LandingPreviewStageHeaderProps) {
return (
<div className='flex h-[44px] flex-shrink-0 items-center gap-1.5 border-[var(--border)] border-b px-4'>
<div className='flex min-w-0 flex-1 items-center overflow-hidden'>
<span className='inline-flex h-[30px] min-w-0 items-center justify-start gap-1.5 rounded-lg px-2 text-left'>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-muted)]' />
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>{name}</span>
</span>
</div>
<div className='ml-auto flex flex-shrink-0 items-center gap-1.5'>
<span className='flex size-[30px] items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
<X className='size-[14px] text-[var(--text-muted)]' />
</span>
<span className='-mr-[9px] flex size-[30px] items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'>
<PanelRight className='size-[16px] text-[var(--text-muted)]' />
</span>
</div>
</div>
)
}
@@ -0,0 +1,569 @@
'use client'
import { useState } from 'react'
import { Checkbox, cn } from '@sim/emcn'
import {
ChevronDown,
Columns3,
Rows3,
Table,
TypeBoolean,
TypeNumber,
TypeText,
} from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { ownerCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/utils'
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
const CELL_CHECKBOX =
'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none'
const CELL_HEADER =
'border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'
const CELL_HEADER_CHECKBOX =
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
const CELL_CONTENT =
'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small'
const SELECTION_OVERLAY =
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px z-[5] border-[2px] border-[var(--selection)]'
const LIST_COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
{ id: 'rows', header: 'Rows' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
]
const TABLE_METAS: Record<string, string> = {
'1': 'Customer Leads',
'2': 'Product Catalog',
'3': 'Campaign Analytics',
'4': 'User Profiles',
'5': 'Invoice Records',
}
const TABLE_ICON = <Table className='size-[14px]' />
const COLUMNS_ICON = <Columns3 className='size-[14px]' />
const ROWS_ICON = <Rows3 className='size-[14px]' />
const LIST_ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: TABLE_ICON, label: 'Customer Leads' },
columns: { icon: COLUMNS_ICON, label: '8' },
rows: { icon: ROWS_ICON, label: '2,847' },
created: { label: '2 days ago' },
owner: ownerCell('S', 'Sarah K.'),
},
},
{
id: '2',
cells: {
name: { icon: TABLE_ICON, label: 'Product Catalog' },
columns: { icon: COLUMNS_ICON, label: '12' },
rows: { icon: ROWS_ICON, label: '1,203' },
created: { label: '5 days ago' },
owner: ownerCell('A', 'Alex M.'),
},
},
{
id: '3',
cells: {
name: { icon: TABLE_ICON, label: 'Campaign Analytics' },
columns: { icon: COLUMNS_ICON, label: '6' },
rows: { icon: ROWS_ICON, label: '534' },
created: { label: '1 week ago' },
owner: ownerCell('W', 'Emaan K.'),
},
},
{
id: '4',
cells: {
name: { icon: TABLE_ICON, label: 'User Profiles' },
columns: { icon: COLUMNS_ICON, label: '15' },
rows: { icon: ROWS_ICON, label: '18,492' },
created: { label: '2 weeks ago' },
owner: ownerCell('J', 'Jordan P.'),
},
},
{
id: '5',
cells: {
name: { icon: TABLE_ICON, label: 'Invoice Records' },
columns: { icon: COLUMNS_ICON, label: '9' },
rows: { icon: ROWS_ICON, label: '742' },
created: { label: 'March 15th, 2026' },
owner: ownerCell('S', 'Sarah K.'),
},
},
]
interface SpreadsheetColumn {
id: string
label: string
type: 'text' | 'number' | 'boolean'
width: number
}
interface SpreadsheetRow {
id: string
cells: Record<string, string>
}
const COLUMN_TYPE_ICONS = {
text: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
} as const
const SPREADSHEET_DATA: Record<string, { columns: SpreadsheetColumn[]; rows: SpreadsheetRow[] }> = {
'1': {
columns: [
{ id: 'name', label: 'Name', type: 'text', width: 160 },
{ id: 'email', label: 'Email', type: 'text', width: 200 },
{ id: 'company', label: 'Company', type: 'text', width: 160 },
{ id: 'score', label: 'Score', type: 'number', width: 100 },
{ id: 'qualified', label: 'Qualified', type: 'boolean', width: 120 },
],
rows: [
{
id: '1',
cells: {
name: 'Alice Johnson',
email: 'alice@acme.com',
company: 'Acme Corp',
score: '87',
qualified: 'true',
},
},
{
id: '2',
cells: {
name: 'Bob Williams',
email: 'bob@techco.io',
company: 'TechCo',
score: '62',
qualified: 'false',
},
},
{
id: '3',
cells: {
name: 'Carol Davis',
email: 'carol@startup.co',
company: 'StartupCo',
score: '94',
qualified: 'true',
},
},
{
id: '4',
cells: {
name: 'Dan Miller',
email: 'dan@bigcorp.com',
company: 'BigCorp',
score: '71',
qualified: 'true',
},
},
{
id: '5',
cells: {
name: 'Eva Chen',
email: 'eva@design.io',
company: 'Design IO',
score: '45',
qualified: 'false',
},
},
{
id: '6',
cells: {
name: 'Frank Lee',
email: 'frank@ventures.co',
company: 'Ventures',
score: '88',
qualified: 'true',
},
},
],
},
'2': {
columns: [
{ id: 'sku', label: 'SKU', type: 'text', width: 120 },
{ id: 'name', label: 'Product Name', type: 'text', width: 200 },
{ id: 'price', label: 'Price', type: 'number', width: 100 },
{ id: 'stock', label: 'In Stock', type: 'number', width: 120 },
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
],
rows: [
{
id: '1',
cells: {
sku: 'PRD-001',
name: 'Wireless Headphones',
price: '79.99',
stock: '234',
active: 'true',
},
},
{
id: '2',
cells: { sku: 'PRD-002', name: 'USB-C Hub', price: '49.99', stock: '89', active: 'true' },
},
{
id: '3',
cells: {
sku: 'PRD-003',
name: 'Laptop Stand',
price: '39.99',
stock: '0',
active: 'false',
},
},
{
id: '4',
cells: {
sku: 'PRD-004',
name: 'Mechanical Keyboard',
price: '129.99',
stock: '52',
active: 'true',
},
},
{
id: '5',
cells: { sku: 'PRD-005', name: 'Webcam HD', price: '89.99', stock: '17', active: 'true' },
},
{
id: '6',
cells: {
sku: 'PRD-006',
name: 'Mouse Pad XL',
price: '24.99',
stock: '0',
active: 'false',
},
},
],
},
'3': {
columns: [
{ id: 'campaign', label: 'Campaign', type: 'text', width: 180 },
{ id: 'clicks', label: 'Clicks', type: 'number', width: 100 },
{ id: 'conversions', label: 'Conversions', type: 'number', width: 140 },
{ id: 'spend', label: 'Spend ($)', type: 'number', width: 130 },
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
],
rows: [
{
id: '1',
cells: {
campaign: 'Spring Sale 2026',
clicks: '12,847',
conversions: '384',
spend: '2,400',
active: 'true',
},
},
{
id: '2',
cells: {
campaign: 'Email Reactivation',
clicks: '3,201',
conversions: '97',
spend: '450',
active: 'false',
},
},
{
id: '3',
cells: {
campaign: 'Referral Program',
clicks: '8,923',
conversions: '210',
spend: '1,100',
active: 'true',
},
},
{
id: '4',
cells: {
campaign: 'Product Launch',
clicks: '24,503',
conversions: '891',
spend: '5,800',
active: 'true',
},
},
{
id: '5',
cells: {
campaign: 'Retargeting Q1',
clicks: '6,712',
conversions: '143',
spend: '980',
active: 'false',
},
},
],
},
'4': {
columns: [
{ id: 'username', label: 'Username', type: 'text', width: 140 },
{ id: 'email', label: 'Email', type: 'text', width: 200 },
{ id: 'plan', label: 'Plan', type: 'text', width: 120 },
{ id: 'seats', label: 'Seats', type: 'number', width: 100 },
{ id: 'active', label: 'Active', type: 'boolean', width: 100 },
],
rows: [
{
id: '1',
cells: {
username: 'alice_j',
email: 'alice@acme.com',
plan: 'Pro',
seats: '5',
active: 'true',
},
},
{
id: '2',
cells: {
username: 'bobw',
email: 'bob@techco.io',
plan: 'Starter',
seats: '1',
active: 'true',
},
},
{
id: '3',
cells: {
username: 'carol_d',
email: 'carol@startup.co',
plan: 'Enterprise',
seats: '25',
active: 'true',
},
},
{
id: '4',
cells: {
username: 'dan.m',
email: 'dan@bigcorp.com',
plan: 'Pro',
seats: '10',
active: 'false',
},
},
{
id: '5',
cells: {
username: 'eva_chen',
email: 'eva@design.io',
plan: 'Starter',
seats: '1',
active: 'true',
},
},
{
id: '6',
cells: {
username: 'frank_lee',
email: 'frank@ventures.co',
plan: 'Enterprise',
seats: '50',
active: 'true',
},
},
],
},
'5': {
columns: [
{ id: 'invoice', label: 'Invoice #', type: 'text', width: 140 },
{ id: 'client', label: 'Client', type: 'text', width: 160 },
{ id: 'amount', label: 'Amount ($)', type: 'number', width: 130 },
{ id: 'paid', label: 'Paid', type: 'boolean', width: 80 },
],
rows: [
{
id: '1',
cells: { invoice: 'INV-2026-001', client: 'Acme Corp', amount: '4,800.00', paid: 'true' },
},
{
id: '2',
cells: { invoice: 'INV-2026-002', client: 'TechCo', amount: '1,200.00', paid: 'true' },
},
{
id: '3',
cells: { invoice: 'INV-2026-003', client: 'StartupCo', amount: '750.00', paid: 'false' },
},
{
id: '4',
cells: { invoice: 'INV-2026-004', client: 'BigCorp', amount: '12,500.00', paid: 'true' },
},
{
id: '5',
cells: { invoice: 'INV-2026-005', client: 'Design IO', amount: '3,300.00', paid: 'false' },
},
],
},
}
interface SpreadsheetViewProps {
tableId: string
tableName: string
onBack: () => void
}
function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
const data = SPREADSHEET_DATA[tableId] ?? SPREADSHEET_DATA['1']
const [selectedCell, setSelectedCell] = useState<{ row: string; col: string } | null>(null)
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Breadcrumb header - matches real Resource.Header breadcrumb layout */}
<div className='border-[var(--border)] border-b px-4 py-[8.5px]'>
<div className='flex items-center gap-3'>
<button
type='button'
onClick={onBack}
className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-secondary)] text-sm transition-colors hover-hover:text-[var(--text-body)]'
>
<Table className='mr-3 size-[14px] text-[var(--text-icon)]' />
Tables
</button>
<span className='select-none text-[var(--text-icon)] text-sm'>/</span>
<span className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-body)] text-sm'>
{tableName}
<ChevronDown className='ml-2 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</span>
</div>
</div>
{/* Spreadsheet - matches exact real table editor structure */}
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
<table className='table-fixed border-separate border-spacing-0 text-small'>
<colgroup>
<col style={{ width: 40 }} />
{data.columns.map((col) => (
<col key={col.id} style={{ width: col.width }} />
))}
</colgroup>
<thead className='sticky top-0 z-10'>
<tr>
<th className={CELL_HEADER_CHECKBOX} aria-label='Row number' />
{data.columns.map((col) => {
const Icon = COLUMN_TYPE_ICONS[col.type] ?? TypeText
return (
<th key={col.id} className={CELL_HEADER}>
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{col.label}
</span>
<ChevronDown className='ml-auto h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{data.rows.map((row, rowIdx) => (
<tr key={row.id}>
<td className={cn(CELL_CHECKBOX, 'text-center')}>
<span className='text-[var(--text-tertiary)] text-xs tabular-nums'>
{rowIdx + 1}
</span>
</td>
{data.columns.map((col) => {
const isSelected = selectedCell?.row === row.id && selectedCell?.col === col.id
const cellValue = row.cells[col.id] ?? ''
return (
<td
key={col.id}
onClick={() => setSelectedCell({ row: row.id, col: col.id })}
className={cn(
CELL,
'relative cursor-default text-[var(--text-body)]',
isSelected && 'bg-[rgba(37,99,235,0.06)]'
)}
>
{isSelected && <div className={SELECTION_OVERLAY} />}
<div className={CELL_CONTENT}>
{col.type === 'boolean' ? (
<div className='flex min-h-[20px] items-center justify-center'>
<Checkbox
size='sm'
checked={cellValue === 'true'}
aria-label={col.label}
className='pointer-events-none'
/>
</div>
) : (
cellValue
)}
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
const tableViewTransition = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
transition: { duration: 0.25, ease: EASE_OUT },
} as const
export function LandingPreviewTables() {
const [openTableId, setOpenTableId] = useState<string | null>(null)
return (
<LazyMotion features={domAnimation}>
<AnimatePresence mode='wait'>
{openTableId !== null ? (
<m.div
key={`spreadsheet-${openTableId}`}
className='flex h-full flex-1 flex-col'
{...tableViewTransition}
>
<SpreadsheetView
tableId={openTableId}
tableName={TABLE_METAS[openTableId] ?? 'Table'}
onBack={() => setOpenTableId(null)}
/>
</m.div>
) : (
<m.div key='table-list' className='flex h-full flex-1 flex-col' {...tableViewTransition}>
<LandingPreviewResource
icon={Table}
title='Tables'
createLabel='New table'
searchPlaceholder='Search tables...'
columns={LIST_COLUMNS}
rows={LIST_ROWS}
onRowClick={(id) => setOpenTableId(id)}
/>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
)
}
@@ -0,0 +1,154 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import ReactFlow, {
applyEdgeChanges,
applyNodeChanges,
type Edge,
type EdgeProps,
type EdgeTypes,
getSmoothStepPath,
type Node,
type NodeTypes,
type OnEdgesChange,
type OnNodesChange,
ReactFlowProvider,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { PreviewBlockNode } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node'
import {
EASE_OUT,
type PreviewWorkflow,
toReactFlowElements,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
interface LandingPreviewWorkflowProps {
workflow: PreviewWorkflow
animate?: boolean
}
/**
* Custom edge that draws left-to-right on initial load via stroke animation.
* Falls back to a static path when `data.animate` is false.
*/
function PreviewEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
data,
}: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
})
if (data?.animate) {
return (
<m.path
id={id}
className='react-flow__edge-path'
d={edgePath}
style={{ ...style, fill: 'none' }}
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
opacity: { duration: 0.15, delay: data.delay ?? 0 },
}}
/>
)
}
return (
<path
id={id}
className='react-flow__edge-path'
d={edgePath}
style={{ ...style, fill: 'none' }}
/>
)
}
const NODE_TYPES: NodeTypes = { previewBlock: PreviewBlockNode }
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
const PRO_OPTIONS = { hideAttribution: true }
const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.5, maxZoom: 1 } as const
/**
* Inner flow component. Keyed on workflow ID by the parent so it remounts
* cleanly on workflow switch - fitView fires on mount with zero delay.
*/
function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => toReactFlowElements(workflow, animate),
[workflow, animate]
)
const [nodes, setNodes] = useState<Node[]>(initialNodes)
const [edges, setEdges] = useState<Edge[]>(initialEdges)
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
)
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[]
)
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{ type: 'previewEdge' }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
zoomOnScroll={false}
zoomOnDoubleClick={false}
panOnScroll={false}
zoomOnPinch={false}
panOnDrag={false}
preventScrolling={false}
autoPanOnNodeDrag={false}
proOptions={PRO_OPTIONS}
minZoom={0.5}
fitView
fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}
className='h-full w-full bg-[var(--surface-2)]'
/>
)
}
/**
* Lightweight ReactFlow canvas displaying a static workflow preview -
* decorative, so panning, dragging, zooming, and selecting are all disabled.
* The key on workflow.id forces a clean remount on switch - instant fitView,
* no timers, no flicker.
*/
export function LandingPreviewWorkflow({ workflow, animate = false }: LandingPreviewWorkflowProps) {
return (
<LazyMotion features={domAnimation}>
<div className='h-full w-full'>
<ReactFlowProvider key={workflow.id}>
<PreviewFlow workflow={workflow} animate={animate} />
</ReactFlowProvider>
</div>
</LazyMotion>
)
}
@@ -0,0 +1,329 @@
'use client'
import { memo } from 'react'
import { Blimp } from '@sim/emcn'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { Database } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import {
AgentIcon,
AnthropicIcon,
FirecrawlIcon,
GeminiIcon,
GithubIcon,
GmailIcon,
GoogleCalendarIcon,
GoogleSheetsIcon,
HubspotIcon,
JiraIcon,
LinearIcon,
LinkedInIcon,
MistralIcon,
NotionIcon,
OpenAIIcon,
RedditIcon,
ReductoIcon,
SalesforceIcon,
ScheduleIcon,
SlackIcon,
StartIcon,
SupabaseIcon,
TelegramIcon,
TextractIcon,
WebhookIcon,
xAIIcon,
xIcon,
YouTubeIcon,
} from '@/components/icons'
import {
BLOCK_STAGGER,
EASE_OUT,
type PreviewTool,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
import { getTileIconColorClass } from '@/blocks/icon-color'
/** Map block type strings to their icon components. */
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
starter: StartIcon,
start_trigger: StartIcon,
agent: AgentIcon,
slack: SlackIcon,
jira: JiraIcon,
x: xIcon,
youtube: YouTubeIcon,
schedule: ScheduleIcon,
telegram: TelegramIcon,
knowledge_base: Database,
webhook: WebhookIcon,
github: GithubIcon,
supabase: SupabaseIcon,
google_calendar: GoogleCalendarIcon,
gmail: GmailIcon,
google_sheets: GoogleSheetsIcon,
hubspot: HubspotIcon,
linear: LinearIcon,
firecrawl: FirecrawlIcon,
reddit: RedditIcon,
notion: NotionIcon,
reducto: ReductoIcon,
salesforce: SalesforceIcon,
textract: TextractIcon,
linkedin: LinkedInIcon,
mothership: Blimp,
}
/** Model prefix → provider icon for the "Model" row in agent blocks. */
const MODEL_PROVIDER_ICONS: Array<{
prefix: string
icon: React.ComponentType<{ className?: string }>
size?: string
}> = [
{ prefix: 'gpt-', icon: OpenAIIcon },
{ prefix: 'o3', icon: OpenAIIcon },
{ prefix: 'o4', icon: OpenAIIcon },
{ prefix: 'claude-', icon: AnthropicIcon },
{ prefix: 'gemini-', icon: GeminiIcon },
{ prefix: 'grok-', icon: xAIIcon, size: 'size-[17px]' },
{ prefix: 'mistral-', icon: MistralIcon },
]
function getModelIconEntry(modelValue: string) {
const lower = modelValue.toLowerCase()
return MODEL_PROVIDER_ICONS.find((m) => lower.startsWith(m.prefix)) ?? null
}
/**
* Data shape for preview block nodes
*/
interface PreviewBlockData {
name: string
blockType: string
bgColor: string
rows: Array<{ title: string; value: string }>
tools?: PreviewTool[]
markdown?: string
hideTargetHandle?: boolean
hideSourceHandle?: boolean
index?: number
animate?: boolean
}
/**
* Handle styling matching the real WorkflowBlock handles.
* --workflow-edge in dark mode: #c9c9c9
*/
const HANDLE_BASE = '!z-[10] !top-5 !-translate-y-1/2 !border-none !bg-[var(--surface-7)]'
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
/**
* Static preview block node matching the real WorkflowBlock styling.
* Renders a block header with icon + name, sub-block rows, and tool chips.
*
* Colors sourced from dark theme CSS variables:
* --surface-2: #ffffff, --border-1: #e6e6e6
* --text-primary: #121212, --text-tertiary: #5f5f5f
*/
export const PreviewBlockNode = memo(function PreviewBlockNode({
data,
}: NodeProps<PreviewBlockData>) {
const {
name,
blockType,
bgColor,
rows,
tools,
markdown,
hideTargetHandle,
hideSourceHandle,
index = 0,
animate = false,
} = data
const Icon = BLOCK_ICONS[blockType]
const delay = animate ? index * BLOCK_STAGGER : 0
if (blockType === 'note' && markdown) {
return (
<LazyMotion features={domAnimation}>
<m.div
className='relative'
initial={animate ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
>
<div className='w-[280px] select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
<div className='border-[var(--border)] border-b p-2'>
<span className='font-medium text-[16px] text-[var(--text-primary)]'>Note</span>
</div>
<div className='p-2.5'>
<NoteMarkdown content={markdown} />
</div>
</div>
</m.div>
</LazyMotion>
)
}
const hasContent = rows.length > 0 || (tools && tools.length > 0)
return (
<LazyMotion features={domAnimation}>
<m.div
className='relative'
initial={animate ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
>
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
{/* Target handle (left side) */}
{!hideTargetHandle && (
<Handle
type='target'
position={Position.Left}
id='target'
className={HANDLE_LEFT}
isConnectableStart={false}
isConnectableEnd={false}
/>
)}
{/* Header */}
<div
className={`flex items-center justify-between p-2 ${hasContent ? 'border-[var(--border)] border-b' : ''}`}
>
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
<div
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ background: bgColor }}
>
{Icon && <Icon className={`size-[16px] ${getTileIconColorClass(bgColor)}`} />}
</div>
<span className='truncate font-medium text-[16px] text-[var(--text-primary)]'>
{name}
</span>
</div>
</div>
{/* Sub-block rows + tools */}
{hasContent && (
<div className='flex flex-col gap-2 p-2'>
{rows.map((row) => {
const modelEntry = row.title === 'Model' ? getModelIconEntry(row.value) : null
const ModelIcon = modelEntry?.icon
return (
<div key={row.title} className='flex items-center gap-2'>
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--text-muted)] capitalize'>
{row.title}
</span>
{row.value && (
<span className='flex min-w-0 flex-1 items-center justify-end gap-2 font-normal text-[14px] text-[var(--text-primary)]'>
{ModelIcon && (
<ModelIcon
className={`inline-block flex-shrink-0 text-[var(--text-primary)] ${modelEntry.size ?? 'size-[14px]'}`}
/>
)}
<span className='truncate'>{row.value}</span>
</span>
)}
</div>
)
})}
{/* Tool chips - inline with label */}
{tools && tools.length > 0 && (
<div className='flex items-center gap-2'>
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--text-muted)]'>
Tools
</span>
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
{tools.map((tool) => {
const ToolIcon = BLOCK_ICONS[tool.type]
return (
<div
key={tool.type}
className='flex items-center gap-[5px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-1)] px-[6px] py-[3px]'
>
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: tool.bgColor }}
>
{ToolIcon && (
<ToolIcon
className={`size-[10px] ${getTileIconColorClass(tool.bgColor)}`}
/>
)}
</div>
<span className='font-normal text-[12px] text-[var(--text-primary)]'>
{tool.name}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Source handle (right side) */}
{!hideSourceHandle && (
<Handle
type='source'
position={Position.Right}
id='source'
className={HANDLE_RIGHT}
isConnectableStart={false}
isConnectableEnd={false}
/>
)}
</div>
</m.div>
</LazyMotion>
)
})
/**
* Renders lightweight markdown-like content for note blocks.
* Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing.
*/
function NoteMarkdown({ content }: { content: string }) {
const lines = content.split('\n')
return (
<div className='flex flex-col gap-1'>
{lines.map((line, i) => {
const trimmed = line.trim()
if (!trimmed) return <div key={`${line}-${i}`} className='h-[4px]' />
if (trimmed === '---') {
return <hr key={`${line}-${i}`} className='my-1 border-[var(--border)] border-t' />
}
if (trimmed.startsWith('### ')) {
return (
<p
key={`${line}-${i}`}
className='font-semibold text-[16px] text-[var(--text-primary)] leading-[1.3]'
>
{trimmed.slice(4)}
</p>
)
}
return (
<p
key={`${line}-${i}`}
className='font-medium text-[13px] text-[var(--text-primary)] leading-[1.5]'
dangerouslySetInnerHTML={{
__html: trimmed
.replace(/\*\*_(.+?)_\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/_"(.+?)"_/g, '<em>&ldquo;$1&rdquo;</em>')
.replace(/_(.+?)_/g, '<em>$1</em>'),
}}
/>
)
})}
</div>
)
}
@@ -0,0 +1,434 @@
import type { Edge, Node } from 'reactflow'
import { Position } from 'reactflow'
/**
* Tool entry displayed as a chip on agent blocks
*/
export interface PreviewTool {
name: string
type: string
bgColor: string
}
/**
* Static block definition for preview workflow nodes
*/
export interface PreviewBlock {
id: string
name: string
type: string
bgColor: string
rows: Array<{ title: string; value: string }>
tools?: PreviewTool[]
markdown?: string
position: { x: number; y: number }
hideTargetHandle?: boolean
hideSourceHandle?: boolean
}
/**
* A scripted Mothership conversation shown in the chat pane beside a staged
* resource - the request a builder typed and Sim's reply. Anchors the
* "chat everywhere" narrative: the chat drives whatever is staged on the right.
*/
export interface PreviewChat {
user: string
assistant: string
}
/**
* Workflow definition containing nodes, edges, and metadata
*/
export interface PreviewWorkflow {
id: string
name: string
color: string
blocks: PreviewBlock[]
edges: Array<{ id: string; source: string; target: string }>
/** The Mothership exchange that produced this workflow, shown in the chat pane. */
chat?: PreviewChat
/** Public JSON export used to seed the landing-page import flow */
seedPath?: string
}
/**
* IT Service Management workflow - Slack Trigger -> Agent (KB tool) -> Jira
*/
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
id: 'wf-it-service',
name: 'IT Service Management',
color: '#2C2C2C',
blocks: [
{
id: 'slack-1',
name: 'Slack',
type: 'slack',
bgColor: '#2C2C2C',
rows: [
{ title: 'Channel', value: '#it-support' },
{ title: 'Event', value: 'New Message' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-1',
name: 'Agent',
type: 'agent',
bgColor: '#2C2C2C',
rows: [
{ title: 'Model', value: 'claude-sonnet-4.6' },
{
title: 'System Prompt',
value:
'Triage incoming IT support requests from Slack, categorize by severity, and create Jira tickets for the appropriate team.',
},
],
tools: [{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#2C2C2C' }],
position: { x: 420, y: 40 },
},
{
id: 'jira-1',
name: 'Jira',
type: 'jira',
bgColor: '#2C2C2C',
rows: [
{ title: 'Operation', value: 'Get Issues' },
{ title: 'Project', value: 'IT-Support' },
],
position: { x: 420, y: 260 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-1', source: 'slack-1', target: 'agent-1' },
{ id: 'e-2', source: 'slack-1', target: 'jira-1' },
],
chat: {
user: 'Set up an IT support agent that answers Slack questions from our docs and files Jira tickets.',
assistant:
'Built IT Service Management. It watches #it-support, answers from your knowledge base, and opens Jira issues for anything it cant resolve.',
},
}
/**
* Self-healing CRM workflow - Schedule -> Agent
*/
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
id: 'wf-self-healing-crm',
name: 'Self-healing CRM',
color: '#2C2C2C',
blocks: [
{
id: 'schedule-1',
name: 'Schedule',
type: 'schedule',
bgColor: '#2C2C2C',
rows: [
{ title: 'Run Frequency', value: 'Daily' },
{ title: 'Time', value: '09:00 AM' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-crm',
name: 'CRM Agent',
type: 'agent',
bgColor: '#2C2C2C',
rows: [
{ title: 'Model', value: 'gpt-5.4' },
{
title: 'System Prompt',
value:
'Audit CRM records, identify data inconsistencies, and fix duplicate contacts, missing fields, and stale pipeline entries across HubSpot and Salesforce.',
},
],
tools: [
{ name: 'HubSpot', type: 'hubspot', bgColor: '#2C2C2C' },
{ name: 'Salesforce', type: 'salesforce', bgColor: '#2C2C2C' },
],
position: { x: 420, y: 140 },
hideSourceHandle: true,
},
],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-crm' }],
chat: {
user: 'Build an agent that audits our CRM every morning and fixes bad records.',
assistant:
'Done. Self-healing CRM runs daily at 9 AM, audits HubSpot and Salesforce, and fixes duplicates, missing fields, and stale entries.',
},
}
/**
* Customer Support Agent workflow - Gmail Trigger -> Agent (KB + Notion tools) -> Slack
*/
const CUSTOMER_SUPPORT_WORKFLOW: PreviewWorkflow = {
id: 'wf-customer-support',
name: 'Customer Support Agent',
color: '#2C2C2C',
blocks: [
{
id: 'gmail-1',
name: 'Gmail',
type: 'gmail',
bgColor: '#2C2C2C',
rows: [
{ title: 'Event', value: 'New Email' },
{ title: 'Label', value: 'Support' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-3',
name: 'Support Agent',
type: 'agent',
bgColor: '#2C2C2C',
rows: [
{ title: 'Model', value: 'gpt-5.4' },
{
title: 'System Prompt',
value:
'Resolve customer support issues using the knowledge base, draft a response, and notify the team in Slack.',
},
],
tools: [
{ name: 'Knowledge', type: 'knowledge_base', bgColor: '#2C2C2C' },
{ name: 'Notion', type: 'notion', bgColor: '#2C2C2C' },
],
position: { x: 420, y: 40 },
},
{
id: 'slack-3',
name: 'Slack',
type: 'slack',
bgColor: '#2C2C2C',
rows: [
{ title: 'Channel', value: '#support' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 420, y: 260 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-cs-1', source: 'gmail-1', target: 'agent-3' },
{ id: 'e-cs-2', source: 'gmail-1', target: 'slack-3' },
],
chat: {
user: 'Make a support agent that replies to customer emails using our help docs.',
assistant:
'Here it is. Customer Support Agent triages new support email, drafts replies from your Knowledge Base and Notion, and posts to #support.',
},
}
/**
* Empty "New Agent" workflow - a single note prompting the user to start building
*/
const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
id: 'wf-new-agent',
name: 'New Agent',
color: '#2C2C2C',
blocks: [
{
id: 'note-1',
name: '',
type: 'note',
bgColor: 'transparent',
rows: [],
markdown: '### What will you build?\n\n_"Find Linear todos and send in Slack"_',
position: { x: 0, y: 0 },
hideTargetHandle: true,
hideSourceHandle: true,
},
],
edges: [],
}
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
SELF_HEALING_CRM_WORKFLOW,
IT_SERVICE_WORKFLOW,
CUSTOMER_SUPPORT_WORKFLOW,
NEW_AGENT_WORKFLOW,
]
/** Stagger delay between each block appearing (seconds). */
export const BLOCK_STAGGER = 0.12
/** Shared cubic-bezier easing - fast deceleration, gentle settle. */
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
/** Shared edge style applied to all preview workflow connections */
const EDGE_STYLE = { stroke: '#c9c9c9', strokeWidth: 1.5 } as const
/**
* Converts a PreviewWorkflow to React Flow nodes and edges.
*
* @param workflow - The workflow definition
* @param animate - When true, node/edge data includes animation metadata
*/
export function toReactFlowElements(
workflow: PreviewWorkflow,
animate = false
): {
nodes: Node[]
edges: Edge[]
} {
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
const nodes: Node[] = workflow.blocks.map((block, index) => ({
id: block.id,
type: 'previewBlock',
position: block.position,
data: {
name: block.name,
blockType: block.type,
bgColor: block.bgColor,
rows: block.rows,
tools: block.tools,
markdown: block.markdown,
hideTargetHandle: block.hideTargetHandle,
hideSourceHandle: block.hideSourceHandle,
index,
animate,
},
draggable: true,
selectable: false,
connectable: false,
sourcePosition: Position.Right,
targetPosition: Position.Left,
}))
const edges: Edge[] = workflow.edges.map((e) => {
const sourceIndex = blockIndexMap.get(e.source) ?? 0
return {
id: e.id,
source: e.source,
target: e.target,
type: 'previewEdge',
animated: false,
style: EDGE_STYLE,
sourceHandle: 'source',
targetHandle: 'target',
data: {
animate,
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
},
}
})
return { nodes, edges }
}
/** Block types that carry an editable prompt suitable for the Editor tab. */
const AGENT_BLOCK_TYPES = new Set(['agent', 'mothership'])
export interface EditorPromptData {
blockId: string
blockName: string
blockType: string
bgColor: string
prompt: string
model: string | null
tools: PreviewTool[]
}
/**
* Extracts the editor-facing prompt from the first agent/mothership block.
*
* @returns Block metadata + prompt + model + tools, or `null` when the workflow has no agent.
*/
export function getEditorPrompt(workflow: PreviewWorkflow): EditorPromptData | null {
for (const block of workflow.blocks) {
if (!AGENT_BLOCK_TYPES.has(block.type)) continue
/** Single ordered pass: first row matching either prompt title, and the first Model row. */
let promptRow: (typeof block.rows)[number] | undefined
let modelRow: (typeof block.rows)[number] | undefined
for (const row of block.rows) {
if (!promptRow && (row.title === 'Prompt' || row.title === 'System Prompt')) promptRow = row
if (!modelRow && row.title === 'Model') modelRow = row
}
if (promptRow) {
return {
blockId: block.id,
blockName: block.name,
blockType: block.type,
bgColor: block.bgColor,
prompt: promptRow.value,
model: modelRow?.value ?? null,
tools: block.tools ?? [],
}
}
}
return null
}
/**
* Computes the delay (ms) before the Editor tab should activate.
* Accounts for all block staggers + edge draw durations + a small buffer.
*/
export function getWorkflowAnimationTiming(workflow: PreviewWorkflow): { editorDelay: number } {
const maxBlockIndex = Math.max(0, workflow.blocks.length - 1)
const hasEdges = workflow.edges.length > 0
const edgeDuration = hasEdges ? 0.4 : 0
const buffer = 0.15
const total = maxBlockIndex * BLOCK_STAGGER + BLOCK_STAGGER + edgeDuration + buffer
return { editorDelay: Math.round(total * 1000) }
}
/**
* Scripted chat for the non-workflow resources the demo stages (logs, tables),
* keyed by sidebar view. Keeps the chat pane present and on-topic so the
* "chat everywhere" story holds while a non-workflow resource is staged.
*/
export const RESOURCE_CHATS: Record<string, PreviewChat> = {
logs: {
user: 'How are my agents doing today?',
assistant:
'7 runs today across your agents, 2 errors: Lead Enrichment and Content Moderator. Want me to dig into those?',
},
tables: {
user: 'Show me my data tables.',
assistant:
'Here are your tables: Customer Leads, Product Catalog, and three more. Ask me to query or update any of them.',
},
files: {
user: 'What files do we have in the workspace?',
assistant: 'Pulling up your files. I can summarize, extract, or feed any of them to an agent.',
},
knowledge: {
user: 'Whats in our knowledge base?',
assistant: 'Here are your knowledge bases. Your agents read from these to ground every answer.',
},
'scheduled-tasks': {
user: 'Whats scheduled to run?',
assistant: 'These are your scheduled tasks. I can pause, edit, or add a new one for you.',
},
}
/** The chat shown for a staged resource: the workflow's own exchange, else the view's. */
export function getViewChat(view: string, workflow?: PreviewWorkflow): PreviewChat | null {
if (view === 'workflow') return workflow?.chat ?? null
return RESOURCE_CHATS[view] ?? null
}
/** Milliseconds between each character typed in the Editor prompt animation. */
export const TYPE_INTERVAL_MS = 30
/** Extra pause (ms) after switching to the Editor tab before typing begins. */
export const TYPE_START_BUFFER_MS = 150
/** How long to dwell on a completed step before advancing (ms). */
export const STEP_DWELL_MS = 2500
/**
* Computes the total time (ms) a workflow step occupies, including
* canvas animation, editor typing, and a dwell period.
*/
export function getWorkflowStepDuration(workflow: PreviewWorkflow): number {
const { editorDelay } = getWorkflowAnimationTiming(workflow)
const prompt = getEditorPrompt(workflow)
const typingTime = prompt ? prompt.prompt.length * TYPE_INTERVAL_MS : 0
return editorDelay + TYPE_START_BUFFER_MS + typingTime + STEP_DWELL_MS
}
@@ -0,0 +1,29 @@
'use client'
import { useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import { trackLandingCta } from '@/app/(landing)/track-landing-cta'
/**
* Stores the typed prompt in browser storage and routes to `/signup`, so a
* visitor's first message survives the auth hop. Shared by the landing
* preview's chat pane and the home empty-state input.
*/
export function useLandingSubmit() {
const router = useRouter()
return useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed) return
LandingPromptStorage.store(trimmed)
trackLandingCta({
label: 'Prompt submit',
section: 'landing_preview',
destination: '/signup',
})
router.push('/signup')
},
[router]
)
}
@@ -0,0 +1,55 @@
'use client'
import dynamic from 'next/dynamic'
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'
/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */
const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]'
/**
* Client mount for the {@link LandingPreview} - the heavy, animated workspace
* island (framer-motion + reactflow). Isolated here so the sections that show it
* stay Server Components: only this leaf is `'use client'`.
*
* Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in
* the server-rendered HTML, and gated on viewport proximity via
* {@link useLazyMount} so the below-the-fold previews don't pull the heavy
* bundle into the initial homepage load. A dimension-stable placeholder (the
* preview's exact `aspect-[1116/615]` footprint, filled with the canvas
* surface) holds the space before and during load, so there is zero layout
* shift or flash.
*/
const LandingPreview = dynamic(
() =>
import('@/app/(landing)/components/landing-preview/landing-preview').then(
(mod) => mod.LandingPreview
),
{
ssr: false,
loading: () => <div className={PLACEHOLDER_CLASS} />,
}
)
interface LandingPreviewMountProps {
/** Forwarded to {@link LandingPreview}; `false` renders a static snapshot. */
autoplay?: boolean
/** Forwarded to {@link LandingPreview}; the static snapshot's staged view. */
view?: SidebarView
/** Forwarded to {@link LandingPreview}; the static snapshot's workflow. */
workflowId?: string
}
export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) {
const { ref, inView } = useLazyMount('400px')
return (
<div ref={ref}>
{inView ? (
<LandingPreview autoplay={autoplay} initialView={view} initialWorkflowId={workflowId} />
) : (
<div className={PLACEHOLDER_CLASS} />
)}
</div>
)
}
@@ -0,0 +1,361 @@
'use client'
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { AnimatePresence, domAnimation, LazyMotion, m, type Variants } from 'framer-motion'
import { LandingPreviewChat } from '@/app/(landing)/components/landing-preview/components/landing-preview-chat/landing-preview-chat'
import { LandingPreviewFiles } from '@/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files'
import { LandingPreviewHome } from '@/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home'
import { LandingPreviewKnowledge } from '@/app/(landing)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
import { LandingPreviewLogs } from '@/app/(landing)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
import { LandingPreviewScheduledTasks } from '@/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewSidebar } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewStageHeader } from '@/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header'
import { LandingPreviewTables } from '@/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables'
import { LandingPreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
import {
EASE_OUT,
getViewChat,
getWorkflowStepDuration,
PREVIEW_WORKFLOWS,
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/** Chat-switcher breadcrumb title per non-workflow staged view. */
const CHAT_TITLES: Partial<Record<SidebarView, string>> = {
logs: 'Agent activity',
tables: 'Workspace data',
files: 'Files',
knowledge: 'Knowledge base',
'scheduled-tasks': 'Scheduled tasks',
}
const containerVariants: Variants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.15 },
},
}
const sidebarVariants: Variants = {
hidden: { opacity: 0, x: -12 },
visible: {
opacity: 1,
x: 0,
transition: {
x: { duration: 0.25, ease: EASE_OUT },
opacity: { duration: 0.25, ease: EASE_OUT },
},
},
}
const viewTransition = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.2, ease: EASE_OUT },
} as const
interface DemoStep {
type: 'workflow' | 'home' | 'logs'
workflowId?: string
duration: number
}
const WORKFLOW_MAP = new Map(PREVIEW_WORKFLOWS.map((w) => [w.id, w]))
const HOME_STEP_MS = 12000
const LOGS_STEP_MS = 5000
const DESKTOP_QUERY = '(min-width: 1024px)'
/** SSR-safe desktop media-query subscription for {@link useSyncExternalStore}. */
function subscribeDesktop(onChange: () => void) {
const mql = window.matchMedia(DESKTOP_QUERY)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}
const getDesktopSnapshot = () => window.matchMedia(DESKTOP_QUERY).matches
const getDesktopServerSnapshot = () => true
/** Full desktop sequence: CRM -> home -> logs -> ITSM -> support -> repeat */
const DESKTOP_STEPS: DemoStep[] = [
{
type: 'workflow',
workflowId: 'wf-self-healing-crm',
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-self-healing-crm')!),
},
{ type: 'home', duration: HOME_STEP_MS },
{ type: 'logs', duration: LOGS_STEP_MS },
{
type: 'workflow',
workflowId: 'wf-it-service',
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-it-service')!),
},
{
type: 'workflow',
workflowId: 'wf-customer-support',
duration: getWorkflowStepDuration(WORKFLOW_MAP.get('wf-customer-support')!),
},
]
interface LandingPreviewProps {
/**
* When false, render a static snapshot: no auto-cycle, no entrance/data-flow
* animation. Used when the preview is a faded backdrop behind an elevated
* callout rather than the live demo.
*/
autoplay?: boolean
/**
* Initial staged view for the static snapshot (`autoplay={false}`). Defaults
* to `'workflow'`. Lets each feature stage show the platform surface that
* matches its callout (e.g. `'logs'`, `'scheduled-tasks'`).
*/
initialView?: SidebarView
/** Initial workflow for the static snapshot. Defaults to the first preview workflow. */
initialWorkflowId?: string
}
/**
* Interactive workspace preview for the hero section.
*
* Desktop: auto-cycles CRM -> home -> logs -> ITSM -> support -> repeat.
* Mobile: static workflow canvas (no animation, no cycling).
* User interaction permanently stops the auto-cycle.
*
* Pass `autoplay={false}` to render a fully static snapshot (no cycling, no
* animation) - for use as a background behind a feature callout.
*/
export function LandingPreview({
autoplay = true,
initialView = 'workflow',
initialWorkflowId = PREVIEW_WORKFLOWS[0].id,
}: LandingPreviewProps) {
const [activeView, setActiveView] = useState<SidebarView>(initialView)
const [activeWorkflowId, setActiveWorkflowId] = useState(initialWorkflowId)
const [animationKey, setAnimationKey] = useState(0)
const [autoTypeHome, setAutoTypeHome] = useState(false)
const isDesktop = useSyncExternalStore(
subscribeDesktop,
getDesktopSnapshot,
getDesktopServerSnapshot
)
const demoIndexRef = useRef(0)
const demoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const autoCycleActiveRef = useRef(true)
const clearDemoTimer = useCallback(() => {
if (demoTimerRef.current) {
clearTimeout(demoTimerRef.current)
demoTimerRef.current = null
}
}, [])
const applyDemoStep = useCallback((step: DemoStep) => {
setAutoTypeHome(false)
if (step.type === 'workflow' && step.workflowId) {
setActiveWorkflowId(step.workflowId)
setActiveView('workflow')
setAnimationKey((k) => k + 1)
} else if (step.type === 'home') {
setActiveView('home')
setAutoTypeHome(true)
} else if (step.type === 'logs') {
setActiveView('logs')
}
}, [])
const scheduleNextStep = useCallback(() => {
if (!autoCycleActiveRef.current) return
const steps = DESKTOP_STEPS
const currentStep = steps[demoIndexRef.current]
demoTimerRef.current = setTimeout(() => {
if (!autoCycleActiveRef.current) return
demoIndexRef.current = (demoIndexRef.current + 1) % steps.length
applyDemoStep(steps[demoIndexRef.current])
scheduleNextStep()
}, currentStep.duration)
}, [applyDemoStep])
useEffect(() => {
/** `isDesktop` is reactive, so gate on the cycle still being active - a resize after interaction must not restart. */
if (!isDesktop || !autoplay || !autoCycleActiveRef.current) return
/** Reset the index so a restart replays from step 0 (index, applied step, and delay stay consistent). */
demoIndexRef.current = 0
applyDemoStep(DESKTOP_STEPS[0])
scheduleNextStep()
return clearDemoTimer
}, [isDesktop, autoplay, applyDemoStep, scheduleNextStep, clearDemoTimer])
const stopAutoCycle = useCallback(() => {
autoCycleActiveRef.current = false
clearDemoTimer()
}, [clearDemoTimer])
const handleSelectWorkflow = useCallback(
(id: string) => {
stopAutoCycle()
setAutoTypeHome(false)
setActiveWorkflowId(id)
setActiveView('workflow')
setAnimationKey((k) => k + 1)
},
[stopAutoCycle]
)
const handleSelectHome = useCallback(() => {
stopAutoCycle()
setAutoTypeHome(false)
setActiveView('home')
}, [stopAutoCycle])
const handleSelectNav = useCallback(
(id: SidebarView) => {
stopAutoCycle()
setAutoTypeHome(false)
setActiveView(id)
},
[stopAutoCycle]
)
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
const isWorkflowView = activeView === 'workflow'
const isHomeView = activeView === 'home'
const chatName = isWorkflowView ? activeWorkflow.name : (CHAT_TITLES[activeView] ?? 'New chat')
/** Desktop demo motion only runs when autoplaying; otherwise a static snapshot. */
const animated = isDesktop && autoplay
return (
<LazyMotion features={domAnimation}>
<m.div
className='flex aspect-[1116/615] w-full overflow-hidden rounded bg-[var(--surface-1)] antialiased'
initial={animated ? 'hidden' : false}
animate='visible'
variants={containerVariants}
>
<m.div className='hidden lg:flex' variants={sidebarVariants}>
<LandingPreviewSidebar
workflows={PREVIEW_WORKFLOWS}
activeWorkflowId={activeWorkflowId}
activeView={activeView}
onSelectWorkflow={handleSelectWorkflow}
onSelectHome={handleSelectHome}
onSelectNav={handleSelectNav}
/>
</m.div>
<div className='flex min-w-0 flex-1 flex-col py-2 pr-2 pl-2 lg:pl-0'>
<div className='flex flex-1 overflow-hidden rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-2)]'>
{isHomeView ? (
/* Home: the chat IS the whole view - its empty "What should we get
done?" state, with no resource staged. */
<div className='relative flex min-w-0 flex-1 flex-col overflow-hidden'>
{animated ? (
<AnimatePresence mode='wait'>
<m.div
key={`home-${animationKey}`}
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewHome autoType={autoTypeHome} />
</m.div>
</AnimatePresence>
) : (
<LandingPreviewHome autoType={autoTypeHome} />
)}
</div>
) : (
/* Chat everywhere: the persistent Mothership chat pane on the left,
a single staged resource on the right. The chat pane stays
mounted as the staged resource crossfades - the chat is the
constant the work hangs off of. */
<>
<m.div className='hidden lg:flex' variants={sidebarVariants}>
<LandingPreviewChat
chat={isDesktop ? getViewChat(activeView, activeWorkflow) : null}
chatName={chatName}
animationKey={animationKey}
/>
</m.div>
<div className='relative flex min-w-0 flex-1 flex-col overflow-hidden border-[var(--border-1)] border-l'>
{isWorkflowView && <LandingPreviewStageHeader name={activeWorkflow.name} />}
<div className='relative min-h-0 flex-1 overflow-hidden'>
{animated ? (
<AnimatePresence mode='wait'>
{activeView === 'workflow' && (
<m.div
key={`wf-${activeWorkflow.id}-${animationKey}`}
className='h-full w-full'
{...viewTransition}
>
<LandingPreviewWorkflow workflow={activeWorkflow} animate />
</m.div>
)}
{activeView === 'tables' && (
<m.div
key={`tables-${animationKey}`}
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewTables />
</m.div>
)}
{activeView === 'files' && (
<m.div
key='files'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewFiles />
</m.div>
)}
{activeView === 'knowledge' && (
<m.div
key='knowledge'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewKnowledge />
</m.div>
)}
{activeView === 'logs' && (
<m.div key='logs' className='flex h-full w-full flex-col' initial={false}>
<LandingPreviewLogs />
</m.div>
)}
{activeView === 'scheduled-tasks' && (
<m.div
key='scheduled-tasks'
className='flex h-full w-full flex-col'
{...viewTransition}
>
<LandingPreviewScheduledTasks />
</m.div>
)}
</AnimatePresence>
) : activeView === 'tables' ? (
<LandingPreviewTables />
) : activeView === 'files' ? (
<LandingPreviewFiles />
) : activeView === 'knowledge' ? (
<LandingPreviewKnowledge />
) : activeView === 'logs' ? (
<LandingPreviewLogs />
) : activeView === 'scheduled-tasks' ? (
<LandingPreviewScheduledTasks />
) : (
<LandingPreviewWorkflow workflow={activeWorkflow} />
)}
</div>
</div>
</>
)}
</div>
</div>
</m.div>
</LazyMotion>
)
}
@@ -0,0 +1 @@
export { LandingShell } from './landing-shell'
@@ -0,0 +1,52 @@
import type { ReactNode } from 'react'
import { getGitHubStars } from '@/lib/github/stars'
import { Footer } from '@/app/(landing)/components/footer/footer'
import { Navbar } from '@/app/(landing)/components/navbar/navbar'
import { SiteStructuredData } from '@/app/(landing)/components/site-structured-data'
/**
* The shared chrome every landing-family page wears - the home page, every
* platform page (Workflows, Tables, Files, …), and every solutions page (IT,
* Engineering, …). It is the single source of truth for the page frame, so each
* page is just `<LandingShell>{content}</LandingShell>` and can never drift.
*
* It owns:
* - The `light` wrapper, which pins every `var(--*)` design token to its
* light-mode value regardless of the visitor's theme - the landing family is
* always light, and uses the platform's own light-mode tokens (from
* `globals.css`) with no separate palette.
* - The page's scroll port (`h-screen` + `overflow-y-auto` +
* `overscroll-y-none`): the document body no longer overflows, so the viewport
* can't rubber-band, and the container's own overscroll bounce is disabled -
* without this the sticky navbar gets dragged past the top/bottom edges.
* - The skip link (targets `#main-content`), the {@link Navbar} (GitHub stars
* fetched here at build/revalidate time - never client-fetched), and the
* {@link Footer}.
*
* The page supplies only the `<main id='main-content'>` content between the
* navbar and footer. Async Server Component; pages render it with zero props.
*/
interface LandingShellProps {
/** The page's `<main id='main-content'>` region - the only content the shell wraps. */
children: ReactNode
}
export async function LandingShell({ children }: LandingShellProps) {
const stars = await getGitHubStars()
return (
<div className='light h-screen overflow-y-auto overscroll-y-none bg-[var(--bg)] text-[var(--text-primary)]'>
<SiteStructuredData />
<a
href='#main-content'
className='sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[var(--z-toast)] focus:rounded-md focus:bg-[var(--surface-2)] focus:px-4 focus:py-2 focus:text-[var(--text-primary)] focus:text-sm'
>
Skip to main content
</a>
<Navbar stars={stars} />
{children}
<Footer />
</div>
)
}
@@ -0,0 +1 @@
export { BuildIcon, DeployIcon, MonitorIcon } from './lifecycle-icons'
@@ -0,0 +1 @@
export { BuildIcon, DeployIcon, MonitorIcon } from './lifecycle-icons'
@@ -0,0 +1,129 @@
import type { SVGProps } from 'react'
/**
* Isometric line-art icons for the lifecycle axes - Build, Deploy, Monitor.
*
* Each icon is built from one primitive: an {@link IsoBox} described in screen
* space (a top rhombus of half-width `w` extruded down by height `h`). Because a
* box is centered on `cx` by construction, symmetric arrangements are trivial,
* and every visible face is filled with `var(--bg)` so a nearer box cleanly
* occludes the edges of one behind it - the wireframe technique that keeps the
* forms readable without shading. Draw boxes far → near.
*
* All three share the 132×120 viewBox and inherit color from `currentColor`, so
* the section sets one tone (`--text-muted`) and the strokes follow. Purely
* decorative - the parent marks each `aria-hidden`.
*/
const VIEW = { w: 132, h: 120 } as const
interface IsoBoxSpec {
/** Horizontal center of the box (screen px). */
cx: number
/** Vertical center of the top rhombus (screen px). */
topY: number
/** Half-width of the top rhombus; its half-height is `w / 2` (isometric). */
w: number
/** Vertical extrusion height. */
h: number
}
/** The three visible faces of an isometric box as SVG path `d` strings. */
function isoFaces({ cx, topY, w, h }: IsoBoxSpec) {
const rh = w / 2
const back = `${cx},${topY - rh}`
const right = `${cx + w},${topY}`
const front = `${cx},${topY + rh}`
const left = `${cx - w},${topY}`
const leftB = `${cx - w},${topY + h}`
const frontB = `${cx},${topY + rh + h}`
const rightB = `${cx + w},${topY + h}`
return {
top: `M${back} L${right} L${front} L${left} Z`,
left: `M${left} L${front} L${frontB} L${leftB} Z`,
right: `M${right} L${front} L${frontB} L${rightB} Z`,
}
}
function IsoBox(spec: IsoBoxSpec) {
const { top, left, right } = isoFaces(spec)
return (
<>
<path d={left} />
<path d={right} />
<path d={top} />
</>
)
}
type IconProps = SVGProps<SVGSVGElement>
function IconFrame({ children, ...props }: IconProps) {
return (
<svg
width={VIEW.w}
height={VIEW.h}
viewBox={`0 0 ${VIEW.w} ${VIEW.h}`}
fill='none'
aria-hidden='true'
{...props}
>
<g
fill='var(--bg)'
stroke='currentColor'
strokeWidth={1.2}
strokeLinejoin='round'
strokeLinecap='round'
>
{children}
</g>
</svg>
)
}
/** Build - a block descending onto a stack of layers, as if assembling an agent. */
export function BuildIcon(props: IconProps) {
return (
<IconFrame {...props}>
<IsoBox cx={66} topY={78} w={24} h={7} />
<IsoBox cx={66} topY={67} w={24} h={7} />
<IsoBox cx={66} topY={56} w={24} h={7} />
<IsoBox cx={66} topY={36} w={24} h={7} />
<path d='M66,31 L75,36 L66,41 L57,36 Z' fill='none' opacity={0.6} />
</IconFrame>
)
}
/** Deploy - instances shipping out: a tall unit flanked by two smaller cubes. */
export function DeployIcon(props: IconProps) {
return (
<IconFrame {...props}>
<IsoBox cx={66} topY={46} w={18} h={28} />
<IsoBox cx={44} topY={64} w={14} h={18} />
<IsoBox cx={88} topY={64} w={14} h={18} />
<circle cx={66} cy={42} r={1.3} fill='currentColor' stroke='none' />
<circle cx={44} cy={61} r={1.3} fill='currentColor' stroke='none' />
<circle cx={88} cy={61} r={1.3} fill='currentColor' stroke='none' />
</IconFrame>
)
}
const MONITOR_BARS = [
{ x: 44, ground: 86, h: 18 },
{ x: 55, ground: 80.5, h: 30 },
{ x: 66, ground: 75, h: 22 },
{ x: 77, ground: 69.5, h: 38 },
{ x: 88, ground: 64, h: 28 },
] as const
/** Monitor - a run of metric bars receding into the distance, like a live chart. */
export function MonitorIcon(props: IconProps) {
return (
<IconFrame {...props}>
{/* Far → near so nearer bars occlude the ones behind them. */}
{[...MONITOR_BARS].reverse().map((bar) => (
<IsoBox key={bar.x} cx={bar.x} topY={bar.ground - bar.h} w={5} h={bar.h} />
))}
</IconFrame>
)
}
@@ -0,0 +1 @@
export { Lifecycle } from './lifecycle'
@@ -0,0 +1,83 @@
import type { ComponentType, SVGProps } from 'react'
import {
BuildIcon,
DeployIcon,
MonitorIcon,
} from '@/app/(landing)/components/lifecycle/components/lifecycle-icons'
/**
* Landing lifecycle - the three axes along which teams use Sim: build, deploy,
* monitor. A split-statement `<h2>` (headline color + body color, matching the
* features header) tops a three-column grid; each column pairs an isometric
* line-art icon with an `<h3>` axis title and a one-line description.
*
* The columns carry a left hairline (`--border`) for the technical, reference-
* style rhythm; the icon area is `flex-1` so the three titles bottom-align
* regardless of icon height. Icons inherit `--text-muted` via `currentColor`.
*
* Inter-section spacing is owned by the `<main>` flex `gap` in `landing.tsx`;
* this section carries no vertical padding. Horizontal padding (`px-20`) matches
* the navbar and hero so the headline starts on the wordmark's line, and the
* section is capped and centered at the shared `max-w-[1460px]`. First
* section after the hero, above {@link Features}.
*/
interface Axis {
title: string
description: string
Icon: ComponentType<SVGProps<SVGSVGElement>>
}
const AXES: Axis[] = [
{
title: 'Build',
description:
'Build agents visually, in natural language, or with code, wiring up any model and 1,000+ integrations.',
Icon: BuildIcon,
},
{
title: 'Deploy',
description:
'Ship agents to production as APIs, Slack bots, or scheduled jobs, live in a click.',
Icon: DeployIcon,
},
{
title: 'Monitor',
description: 'Trace every run block by block, with full logs of what each agent did and why.',
Icon: MonitorIcon,
},
]
export function Lifecycle() {
return (
<section
id='lifecycle'
aria-labelledby='lifecycle-heading'
className='mx-auto w-full max-w-[1460px] px-20'
>
<h2 id='lifecycle-heading' className='max-w-[1200px] text-balance text-[32px] leading-[1.3]'>
<span className='text-[var(--text-primary)]'>From idea to production.</span>{' '}
<span className='text-[var(--text-body)]'>
Build agents, deploy them, and monitor every run, all in one workspace.
</span>
</h2>
<ul className='mt-20 grid grid-cols-3 gap-12'>
{AXES.map(({ title, description, Icon }) => (
<li
key={title}
className='flex flex-col border-[var(--border)] border-l pl-8 text-[var(--text-muted)]'
>
<div className='flex flex-1 items-center justify-center py-10'>
<Icon />
</div>
<h3 className='text-[18px] text-[var(--text-primary)]'>{title}</h3>
<p className='mt-2 max-w-[300px] text-[15px] text-[var(--text-body)] leading-[1.5]'>
{description}
</p>
</li>
))}
</ul>
</section>
)
}
@@ -0,0 +1 @@
export { Lightbox } from './lightbox'
@@ -0,0 +1,65 @@
'use client'
import { useEffect, useEffectEvent, useRef } from 'react'
interface LightboxProps {
isOpen: boolean
onClose: () => void
src: string
alt: string
}
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
const overlayRef = useRef<HTMLDivElement>(null)
const onCloseEvent = useEffectEvent(onClose)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onCloseEvent()
}
}
const handleClickOutside = (event: MouseEvent) => {
if (overlayRef.current && event.target === overlayRef.current) {
onCloseEvent()
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
document.body.style.overflow = 'unset'
}
}, [isOpen])
if (!isOpen) return null
return (
<div
ref={overlayRef}
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
role='dialog'
aria-modal='true'
aria-label='Image viewer'
>
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
<button type='button' className='block cursor-pointer rounded-xl' onClick={onClose}>
<img
src={src}
alt={alt}
className='max-h-[75vh] max-w-[75vw] rounded-xl object-contain'
loading='lazy'
/>
</button>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More