chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# Landing Page - Build & Optimization Instructions
|
||||
|
||||
This route group owns `/` and the entire public marketing surface - the home page, platform/solutions pages, pricing, legal, and the marketing subroutes (`/blog`, `/library`, `/models`, `/integrations`, `/demo`, `/partners`, `/changelog`). Read this file in full before adding or changing anything here. Positioning and language rules live in `.claude/rules/constitution.md`; SEO/GEO rules in `.claude/rules/landing-seo-geo.md`. Both apply to every file in this directory.
|
||||
|
||||
`/blog` (editorial/company-voice posts) and `/library` (AEO/GEO content - listicles, comparisons, how-tos) are two separate route trees over one shared engine: `apps/sim/lib/content/` (generic registry factory, MDX components, SEO builders) instantiated per-section by the thin `apps/sim/lib/blog/` and `apps/sim/lib/library/` modules, rendered through the shared `Content*Page` components in `components/`. Content lives in `apps/sim/content/blog/` and `apps/sim/content/library/`; both share `apps/sim/content/authors/`. Adding a post to either section, or adding a new content section entirely, must reuse this engine - never hand-roll a divergent registry or page layout. Every new marketing subroute (including any future content section) needs `app/sitemap.ts` and `app/robots.ts` updated, same as `/blog` and `/library` were.
|
||||
|
||||
## What this is
|
||||
|
||||
- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, footer, site-wide JSON-LD); each page supplies only its `<main>` content.
|
||||
- The legacy `app/(home)/` group (old dark landing + `--landing-*` tokens) has been **deleted** - its marketing pages were migrated here and its chrome retired. Do not reintroduce `--landing-*` tokens, Martian Mono accents, or a separate marketing theme.
|
||||
|
||||
## Styling - draw from the platform's light mode
|
||||
|
||||
The landing page looks like the product. Its visual language is the workspace UI in light mode, not a separate marketing theme.
|
||||
|
||||
- **Always light.** The root wrapper in `landing.tsx` carries the `light` class, which pins every token to its light value (see `app/_styles/globals.css`, the `:root, .light` block). Never add `dark:` variants here; never read the user's theme.
|
||||
- **Use platform tokens, never hex.** Canvas `--bg`, surfaces `--surface-1`…`--surface-7`, cards/modals `--surface-2`, hover `--surface-hover`, active `--surface-active`; text `--text-primary` / `--text-secondary` / `--text-muted` / `--text-body`, icons `--text-icon`; borders `--border` (dividers) / `--border-1` (fields); brand `--brand-agent` / `--brand-secondary` / `--brand-accent`. Do **not** use the legacy `--landing-*` tokens - they belong to the old dark landing.
|
||||
- **Use emcn components where they fit.** The chip family (`Chip`, `ChipLink`, `ChipTag`, `ChipInput`, `ChipModal*`, …) from `@/components/emcn` is the canonical chrome - a demo-request form is a `ChipModal` with `ChipModalField`s, a pill CTA is a `Chip`/`ChipLink`. Components own their chrome; pass props, not className overrides. Full consumer rules: `.claude/rules/sim-styling.md`.
|
||||
- **Typography is the platform's.** Season is the global body font (`font-season` is applied on `<body>` in the root layout). Use the platform text scale (`text-small` = 13px, `text-base` = 15px, etc. - see `tailwind.config.ts`). Don't add new fonts or font CSS variables without explicit direction.
|
||||
- **Never touch global styles.** No additions to `app/_styles/globals.css`. All styling is local Tailwind classes; `cn()` from `@/lib/core/utils/cn` for conditionals; no inline `style` attributes.
|
||||
- **Responsive - desktop is the source of truth, scaled down via `max-*` overrides.** The page is fully responsive (iPad + phone). The desktop layout stays the unprefixed baseline; smaller screens are handled by *layering* `max-*` overrides on top, so desktop renders byte-identically. Tiers:
|
||||
- `max-xl:` (≤1279) - the hero's two-panel split (absolute visual + logos) collapses to a stacked, in-flow column. The split needs ≥1280 to avoid the headline colliding with the visual panel; iPad-landscape (1024) therefore gets the stacked hero with the desktop nav.
|
||||
- `max-lg:` (≤1023) - the desktop nav clusters hide (`hidden lg:flex`) and `MobileNav` (hamburger sheet) takes over; multi-column grids step down (mothership 4→2, footer 7→3); shared gutter `px-20 → max-lg:px-8`; section gaps tighten.
|
||||
- `max-md:` (≤767) - Features beats drop the floating callout (`max-md:hidden`) and show the un-masked backdrop preview full-width.
|
||||
- `max-sm:` (≤639) - single-column grids, smallest type scale, `px-5` gutter, hero CTA row stacks.
|
||||
|
||||
When adding a new section, give it the same `px-20 max-lg:px-8 max-sm:px-5` gutter so the navbar wordmark stays aligned with section content at every width. Verify desktop is unchanged and there is zero horizontal overflow at 1280 / 1024 / 768 / 390 before shipping.
|
||||
|
||||
## Performance - page speed is a feature
|
||||
|
||||
Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost.
|
||||
|
||||
- **Server Components by default.** `'use client'` only on the smallest leaf that genuinely needs interactivity (a button with state, not the section containing it). The navbar, hero copy, footer, and every static section must be server-rendered HTML.
|
||||
- **No heavy client libraries above the fold.** No animation frameworks (framer-motion etc.), no ReactFlow, no chart libs in the initial bundle. If a below-fold section truly needs one, load it with `next/dynamic` and a dimension-stable placeholder.
|
||||
- **Images via `next/image` always.** The LCP element (logo or hero visual) gets `priority`; everything below the fold lazy-loads (the default). Every image has explicit `width`/`height` - zero layout shift.
|
||||
- **Prefer CSS over JS.** Hover states, transitions, marquees, and reveal effects in CSS (`transition-*`, `animation`) rather than scroll listeners or animation libraries. Decorative motion respects `prefers-reduced-motion`.
|
||||
- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time or deferred to a client island. A `cookies()`/`headers()`/`unstable_noStore()` call anywhere in the tree - including the root `app/layout.tsx` - silently overrides every page's `revalidate` and forces the whole app dynamic. If a marketing page builds as `ƒ` instead of `○`/`●` (check `bun run build`'s route table), look upstream, not just at the page itself.
|
||||
- **Reserve space for everything.** Fixed dimensions or aspect ratios on all media, embeds, and async content. CLS budget is effectively zero.
|
||||
- **Decorative canvases and animations are non-interactive.** A hand-built product-demo animation or embedded ReactFlow canvas is presentation only - no drag handlers, no `nodesDraggable`/`panOnDrag`/`elementsSelectable` on ReactFlow. A visitor should never be able to click or drag a decorative element.
|
||||
- **Lazy-mount a heavy client island's second occurrence.** If the same animated component appears twice on a page, only the first (usually the hero) loads eagerly - the rest go through a small `'use client'` mount wrapper built on the shared `hooks/use-lazy-mount.ts` hook: `next/dynamic(..., { ssr: false })` gated by the hook's `IntersectionObserver`. See `components/product-demo/components/product-demo-visual-mount/` for the reference pattern, and `.claude/rules/sim-imports.md` for the barrel-cleanup step that must come with it.
|
||||
- **Don't prefetch authenticated-app routes from an always-visible CTA.** `<Link>` prefetches its target route's JS once it's in the viewport - a navbar/hero CTA to `/signup` or `/login` is always in view, so it downloads that route's bundle on every pageview. Pass `prefetch={false}` there. Leave the default on CTAs that only enter the viewport on scroll (prefetch-on-approach is the desired behavior there).
|
||||
|
||||
## SEO
|
||||
|
||||
`page.tsx` owns the metadata (title, description, OG/Twitter, canonical, robots) - keep it the single source of truth and keep it aligned with the constitution's claim hierarchy. Beyond metadata:
|
||||
|
||||
- **One `<h1>`, in the hero, containing "Sim" and "AI workspace".** Strict hierarchy below it: H2 per section, H3 for items within a section. Never skip levels, never add a second H1.
|
||||
- **Semantic landmarks**: `<header>`, `<main>`, `<footer>`, `<nav>`; each section is `<section id="…" aria-labelledby="…-heading">`. Decorative/animated elements get `aria-hidden='true'`.
|
||||
- **Structured data**: emit JSON-LD (`Organization`, `WebSite`, `WebApplication` with `featureList`, `FAQPage` if an FAQ exists) from a server component rendered before visible content. Keep `featureList` in sync with the features actually shown on the page.
|
||||
- **Crawlable links**: all internal navigation uses Next `<Link>` with real `href`s - never `onClick` navigation. External links get `rel='noopener noreferrer'`.
|
||||
- **All copy is server-rendered text.** No text baked into images, no content that only exists after a client effect runs.
|
||||
- After adding routes or anchors, verify `app/sitemap.ts` and `app/robots.ts` still reflect reality.
|
||||
|
||||
## GEO (generative engine optimization)
|
||||
|
||||
AI crawlers and answer engines read this page. Optimize for extraction:
|
||||
|
||||
- **Answer-first sections.** Each H2 + first paragraph should directly answer a question a user would ask an AI ("What is Sim?", "What integrations does Sim support?", "How much does Sim cost?").
|
||||
- **Atomic blocks.** Every feature card, template, and pricing tier must be independently quotable - self-contained, with "Sim" named explicitly. Never "the platform", "our tool", or a bare pronoun as the subject.
|
||||
- **Specific numbers over vague claims.** "1,000+ integrations", "every major LLM", "100,000+ builders" - and only numbers that are true and shipped.
|
||||
- **sr-only summaries.** The hero gets a `<p className='sr-only'>` (~50 words) stating what Sim is, who it's for, and what it does - a clean citation target for AI summarizers.
|
||||
- The first 150 visible characters of the page must include "Sim", "AI workspace", and "AI agents".
|
||||
|
||||
## Copy
|
||||
|
||||
Follow `.claude/rules/constitution.md` exactly: Sim is "the open-source AI workspace where teams build, deploy, and manage AI agents" - never a workflow tool or automation platform. Direct sentences, active voice, concrete examples, no exclamation marks, no unexplained jargon on public pages.
|
||||
|
||||
## Structure
|
||||
|
||||
**Directory convention - mirror `app/workspace/[workspaceId]/<feature>`.** Every component lives in its own kebab-case folder holding `<name>.tsx` plus an `index.ts` barrel (relative re-export); a component's children nest under that folder's `components/` (each itself foldered, with its own `index.ts`). Non-component modules - `types.ts`, `constants.ts`, data files - sit at the relevant folder root. Never leave a bare `<name>.tsx` flat inside a `components/` directory.
|
||||
|
||||
```
|
||||
(landing)/
|
||||
├── CLAUDE.md # this file
|
||||
├── page.tsx # route entry: metadata + <Landing />
|
||||
├── landing.tsx # root composition: <main> section order
|
||||
├── workflows/ # a platform route: page.tsx (metadata) + workflows.tsx (config + shell)
|
||||
├── hooks/ # cross-page client hooks (useLazyMount, …) - bare files, no folder/barrel
|
||||
└── components/
|
||||
├── index.ts # top barrel
|
||||
├── navbar/{navbar.tsx, index.ts, components/<chip>/…} # <header><nav>: wordmark, dropdowns, stars, auth chips
|
||||
├── hero/{hero.tsx, index.ts, components/hero-visual/…} # h1, description, CTA, platform visual, logos
|
||||
├── lifecycle/, features/, footer/, testimonials/ # each: <name>.tsx + index.ts (+ components/)
|
||||
├── shared/ # cross-page reused chrome (folder-per-component + barrel)
|
||||
│ ├── landing-shell/ # light wrapper + skip link + Navbar(stars) + Footer; wraps every page
|
||||
│ ├── hero-cta/ # the one email-capture + Sign-up CTA (hero + every platform hero)
|
||||
│ └── logos/ # the one customer-logo set; layout='grid' (hero) | 'row' (platform)
|
||||
└── platform-page/ # the reusable platform-page layout (Workflows, Tables, Files, …)
|
||||
├── platform-page.tsx, index.ts, types.ts, constants.ts # PlatformPage + the content contract + spacing
|
||||
└── components/ # platform-hero, platform-logos-row, platform-card-row (→ card, pill-cta),
|
||||
# platform-visual-frame, platform-structured-data
|
||||
```
|
||||
|
||||
Each section component's TSDoc carries its layout spec - read it before implementing. Section components own their landmark (Navbar → `<header>`, Footer → `<footer>`, the rest → `<section>`); the shared `LandingShell` owns the page frame (light wrapper, skip link, navbar, footer, GitHub stars via `@/lib/github/stars` - fetched at build/revalidate time, never client-fetched), and the page's `<main>` owns the section order and rhythm. Platform routes consume `PlatformPage` with a single content `config` - see `platform-page/CLAUDE`-level TSDoc on `platform-page.tsx`. Sub-components of a section go in `components/<section>/components/`.
|
||||
|
||||
Absolute imports only in component code (`@/app/(landing)/components/...`); `index.ts` barrels use relative re-exports (`export { X } from './x'`), matching the workspace convention. Props interfaces for every component. No `utils.ts` until two files share a helper.
|
||||
|
||||
## Checklist for every section you add
|
||||
|
||||
1. Server Component unless it provably needs client state; if client, it's a leaf.
|
||||
2. H2 with `id` + `aria-labelledby` wiring; heading hierarchy intact.
|
||||
3. Platform light tokens and emcn chrome only - no hex colors, no `--landing-*`, no `dark:`.
|
||||
4. Images: `next/image`, explicit dimensions, `priority` only on the LCP element.
|
||||
5. Copy passes the constitution (language table, claim hierarchy, tone).
|
||||
6. "Sim" named explicitly; section quotable in isolation.
|
||||
7. JSON-LD updated if the section adds features, FAQs, or pricing.
|
||||
8. No layout shift: load the page, watch nothing move.
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContentPostLoading } from '@/app/(landing)/components'
|
||||
|
||||
export default function BlogPostLoading() {
|
||||
return <ContentPostLoading />
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||
import { BLOG_SECTION, buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { ContentPostPage } from '@/app/(landing)/components'
|
||||
|
||||
export const dynamicParams = false
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getAllPostMeta()
|
||||
return posts.map((p) => ({ slug: p.slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const post = await getPostBySlug(slug)
|
||||
return buildPostMetadata(post)
|
||||
}
|
||||
|
||||
export const revalidate = 86400
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const post = await getPostBySlug(slug)
|
||||
const related = await getRelatedPosts(slug, 3)
|
||||
|
||||
return (
|
||||
<ContentPostPage
|
||||
basePath={BLOG_SECTION.basePath}
|
||||
backLabel='Back to Blog'
|
||||
post={post}
|
||||
related={related}
|
||||
graphJsonLd={buildPostGraphJsonLd(post)}
|
||||
shareUrl={`${getBaseUrl()}${BLOG_SECTION.basePath}/${slug}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContentAuthorLoading } from '@/app/(landing)/components'
|
||||
|
||||
export default function AuthorLoading() {
|
||||
return <ContentAuthorLoading />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { BLOG_SECTION, buildAuthorGraphJsonLd, buildAuthorMetadata } from '@/lib/blog/seo'
|
||||
import { ContentAuthorPage } from '@/app/(landing)/components'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
return buildAuthorMetadata(id, posts[0]?.author)
|
||||
}
|
||||
|
||||
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
const author = posts[0]?.author
|
||||
|
||||
return (
|
||||
<ContentAuthorPage
|
||||
basePath={BLOG_SECTION.basePath}
|
||||
authorName={author?.name}
|
||||
authorAvatarUrl={author?.avatarUrl}
|
||||
posts={posts}
|
||||
graphJsonLd={author ? buildAuthorGraphJsonLd(author) : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Blog route segment. The shared landing layout owns the chrome (navbar, footer,
|
||||
* site-wide JSON-LD, scroll port); this layout only provides the `<main>`
|
||||
* landmark for every blog page. Blog pages emit their own page-specific JSON-LD.
|
||||
*/
|
||||
export default function BlogLayout({ children }: { children: ReactNode }) {
|
||||
return <main id='main-content'>{children}</main>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContentIndexLoading } from '@/app/(landing)/components'
|
||||
|
||||
export default function BlogLoading() {
|
||||
return <ContentIndexLoading />
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { BLOG_SECTION, buildCollectionPageJsonLd, buildIndexMetadata } from '@/lib/blog/seo'
|
||||
import { ContentIndexPage } from '@/app/(landing)/components'
|
||||
|
||||
/**
|
||||
* Filtered/paginated variants render genuinely different lists, but only the
|
||||
* bare index is indexable — see `buildIndexMetadata` in `@/lib/content/seo`
|
||||
* for the shared noindex policy.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; tag?: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { page, tag } = await searchParams
|
||||
const pageNum = Math.max(1, Number(page || 1))
|
||||
return buildIndexMetadata({ tag, pageNum })
|
||||
}
|
||||
|
||||
export default async function BlogIndex({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; tag?: string }>
|
||||
}) {
|
||||
const { page, tag } = await searchParams
|
||||
const pageNum = Math.max(1, Number(page || 1))
|
||||
const posts = await getAllPostMeta()
|
||||
|
||||
return (
|
||||
<ContentIndexPage
|
||||
basePath={BLOG_SECTION.basePath}
|
||||
heading='Latest from Sim'
|
||||
subheading={BLOG_SECTION.description}
|
||||
posts={posts}
|
||||
page={pageNum}
|
||||
tag={tag}
|
||||
collectionJsonLd={buildCollectionPageJsonLd()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { latestModified } from '@/lib/content/utils'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function GET() {
|
||||
const posts = await getAllPostMeta()
|
||||
const items = posts.slice(0, 50)
|
||||
const site = SITE_URL
|
||||
const lastBuildDate = (latestModified(items) ?? new Date()).toUTCString()
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Sim Blog</title>
|
||||
<link>${site}</link>
|
||||
<description>Announcements, insights, and guides for AI agent workflows.</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<atom:link href="${site}/blog/rss.xml" rel="self" type="application/rss+xml" />
|
||||
${items
|
||||
.map(
|
||||
(p) => `
|
||||
<item>
|
||||
<title><![CDATA[${p.title}]]></title>
|
||||
<link>${p.canonical}</link>
|
||||
<guid>${p.canonical}</guid>
|
||||
<pubDate>${new Date(p.date).toUTCString()}</pubDate>
|
||||
<description><![CDATA[${p.description}]]></description>
|
||||
${(p.authors || [p.author])
|
||||
.map((a) => `<author><![CDATA[${a.name}${a.url ? ` (${a.url})` : ''}]]></author>`)
|
||||
.join('\n')}
|
||||
${p.tags.map((t) => `<category><![CDATA[${t}]]></category>`).join('\n ')}
|
||||
</item>`
|
||||
)
|
||||
.join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
return new NextResponse(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function GET() {
|
||||
const posts = await getAllPostMeta()
|
||||
const base = SITE_URL
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||
${posts
|
||||
.map(
|
||||
(p) => `<url>
|
||||
<loc>${p.canonical}</loc>
|
||||
<image:image>
|
||||
<image:loc>${p.ogImage.startsWith('http') ? p.ogImage : `${base}${p.ogImage}`}</image:loc>
|
||||
<image:title><![CDATA[${p.title}]]></image:title>
|
||||
<image:caption><![CDATA[${p.description}]]></image:caption>
|
||||
</image:image>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`
|
||||
return new NextResponse(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContentTagsLoading } from '@/app/(landing)/components'
|
||||
|
||||
export default function TagsLoading() {
|
||||
return <ContentTagsLoading />
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getAllTags } from '@/lib/blog/registry'
|
||||
import { BLOG_SECTION, buildTagsBreadcrumbJsonLd, buildTagsMetadata } from '@/lib/blog/seo'
|
||||
import { ContentTagsPage } from '@/app/(landing)/components'
|
||||
|
||||
export const metadata: Metadata = buildTagsMetadata()
|
||||
|
||||
export default async function TagsIndex() {
|
||||
const tags = await getAllTags()
|
||||
return (
|
||||
<ContentTagsPage
|
||||
basePath={BLOG_SECTION.basePath}
|
||||
tags={tags}
|
||||
breadcrumbJsonLd={buildTagsBreadcrumbJsonLd()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import { getAshbyJobs } from '@/lib/ashby/jobs'
|
||||
import {
|
||||
filterPostings,
|
||||
groupByDepartment,
|
||||
hasActiveFilters,
|
||||
JobBoard,
|
||||
JobGroups,
|
||||
} from '@/app/(landing)/careers/components/job-board'
|
||||
import { careersSearchParamsCache } from '@/app/(landing)/careers/search-params'
|
||||
|
||||
interface CareersProps {
|
||||
searchParams: Promise<SearchParams>
|
||||
}
|
||||
|
||||
/**
|
||||
* The careers page — a mission-led hero above the live open-roles board. Roles
|
||||
* are pulled from Sim's public Ashby job board at build/revalidate time
|
||||
* ({@link getAshbyJobs}) and server-rendered in full, so every posting is in the
|
||||
* crawlable HTML; the interactive {@link JobBoard} hydrates on top to add
|
||||
* Team/Location filtering.
|
||||
*
|
||||
* Both sections share the landing gutter — capped and centered at `max-w-[1460px]`
|
||||
* with the navbar-aligned `px-20 max-lg:px-8 max-sm:px-5` so the headline starts on
|
||||
* the same vertical line as the wordmark. The hero carries the single `<h1>`
|
||||
* (containing "Sim" and "AI workspace") plus an sr-only product summary for AI
|
||||
* citation (landing CLAUDE.md → GEO); the roles section owns its own `<h2>`.
|
||||
*
|
||||
* Because {@link JobBoard} reads the URL via nuqs (`useSearchParams`), it sits under
|
||||
* a `<Suspense>` boundary. The page parses the same `?team=`/`?location=` query on
|
||||
* the server ({@link careersSearchParamsCache}) and pre-filters the fallback to
|
||||
* match, so a deep-linked filter renders the correct roles server-side — the list
|
||||
* never flashes unfiltered before the client board hydrates.
|
||||
*/
|
||||
export default async function Careers({ searchParams }: CareersProps) {
|
||||
const [{ team, location }, postings] = await Promise.all([
|
||||
careersSearchParamsCache.parse(searchParams),
|
||||
getAshbyJobs(),
|
||||
])
|
||||
const fallbackGroups = groupByDepartment(filterPostings(postings, team, location))
|
||||
|
||||
return (
|
||||
<main id='main-content'>
|
||||
<section
|
||||
id='careers-hero'
|
||||
aria-labelledby='careers-heading'
|
||||
className='mx-auto flex w-full max-w-[1460px] flex-col gap-5 px-20 pt-20 pb-10 max-sm:px-5 max-sm:pt-16 max-lg:px-8'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Careers at Sim, the open-source AI workspace where teams build, deploy, and manage AI
|
||||
agents. Sim is hiring engineers, designers, and go-to-market builders to help teams
|
||||
automate real work across 1,000+ integrations and every major LLM — visually,
|
||||
conversationally, or with code.
|
||||
</p>
|
||||
|
||||
<h1
|
||||
id='careers-heading'
|
||||
className='max-w-[24ch] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
|
||||
>
|
||||
Help build Sim, the AI workspace for teams.
|
||||
</h1>
|
||||
<p className='max-w-[60ch] text-pretty text-[var(--text-body)] text-lg leading-[1.5] max-sm:text-base'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. We're
|
||||
a small, high-agency team shipping fast to thousands of builders. If you want to own real
|
||||
work and shape the workspace teams live in, we'd love to meet you.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id='open-roles'
|
||||
aria-labelledby='open-roles-heading'
|
||||
className='mx-auto flex w-full max-w-[1460px] flex-col gap-10 px-20 pt-6 pb-24 max-sm:px-5 max-sm:pb-16 max-lg:px-8'
|
||||
>
|
||||
<h2
|
||||
id='open-roles-heading'
|
||||
className='text-[24px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'
|
||||
>
|
||||
Open roles
|
||||
</h2>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<JobGroups groups={fallbackGroups} filtersActive={hasActiveFilters(team, location)} />
|
||||
}
|
||||
>
|
||||
<JobBoard postings={postings} />
|
||||
</Suspense>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { JobBoard } from './job-board'
|
||||
export { JobGroups } from './job-groups'
|
||||
export { filterPostings, groupByDepartment, hasActiveFilters } from './utils'
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { ChipSelect, type ChipSelectOption } from '@sim/emcn'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import { JobGroups } from '@/app/(landing)/careers/components/job-board/job-groups'
|
||||
import {
|
||||
filterPostings,
|
||||
groupByDepartment,
|
||||
hasActiveFilters,
|
||||
} from '@/app/(landing)/careers/components/job-board/utils'
|
||||
import {
|
||||
ALL_FILTER_VALUE,
|
||||
careersParsers,
|
||||
careersUrlKeys,
|
||||
} from '@/app/(landing)/careers/search-params'
|
||||
|
||||
interface JobBoardProps {
|
||||
postings: CareerPosting[]
|
||||
}
|
||||
|
||||
/** Builds `{ label, value }` options for a filter, with an "All" row at the top. */
|
||||
function toFilterOptions(values: string[], allLabel: string): ChipSelectOption[] {
|
||||
return [
|
||||
{ label: allLabel, value: ALL_FILTER_VALUE },
|
||||
...values.map((value) => ({ label: value, value })),
|
||||
]
|
||||
}
|
||||
|
||||
/** Distinct, alphabetically sorted values from a list. */
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
/**
|
||||
* The interactive open-roles board — the single `'use client'` leaf on the
|
||||
* careers page. Every posting is server-rendered into the HTML (via the static
|
||||
* {@link JobGroups} Suspense fallback in `careers.tsx`), so all roles stay
|
||||
* crawlable; this leaf hydrates on top to add Team/Location filtering. Filter
|
||||
* state lives in the URL via nuqs (`?team=`/`?location=`) so a filtered view is
|
||||
* shareable and survives reload/back-forward. The filter set is small and
|
||||
* static, so filtering reads the instant URL value directly (no debounce).
|
||||
*/
|
||||
export function JobBoard({ postings }: JobBoardProps) {
|
||||
const [{ team, location }, setFilters] = useQueryStates(careersParsers, careersUrlKeys)
|
||||
|
||||
const teamOptions = toFilterOptions(uniqueSorted(postings.map((p) => p.department)), 'All teams')
|
||||
const locationOptions = toFilterOptions(
|
||||
uniqueSorted(postings.flatMap((p) => (p.location ? [p.location] : []))),
|
||||
'All locations'
|
||||
)
|
||||
const groups = groupByDepartment(filterPostings(postings, team, location))
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-10'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<ChipSelect
|
||||
options={teamOptions}
|
||||
value={team}
|
||||
onChange={(value) => setFilters({ team: value })}
|
||||
aria-label='Filter roles by team'
|
||||
/>
|
||||
<ChipSelect
|
||||
options={locationOptions}
|
||||
value={location}
|
||||
onChange={(value) => setFilters({ location: value })}
|
||||
aria-label='Filter roles by location'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JobGroups groups={groups} filtersActive={hasActiveFilters(team, location)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { ArrowRight } from '@sim/emcn/icons'
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import type { DepartmentGroup } from '@/app/(landing)/careers/components/job-board/utils'
|
||||
|
||||
/** Empty-state copy: distinguishes a truly empty board from a filtered-to-zero view. */
|
||||
const NO_OPEN_ROLES_MESSAGE = 'No open roles right now — check back soon.'
|
||||
const NO_MATCHING_ROLES_MESSAGE =
|
||||
'No roles match these filters right now. Try clearing them, or check back soon.'
|
||||
|
||||
interface JobGroupsProps {
|
||||
groups: DepartmentGroup[]
|
||||
/**
|
||||
* Whether a Team/Location filter is active. Selects the empty-state copy so an
|
||||
* unfiltered empty board ("no open roles") never reads as a filtered miss ("no
|
||||
* matches") — and the server fallback and client board always agree.
|
||||
*/
|
||||
filtersActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The presentational open-roles list: one labeled section per department, each a
|
||||
* list of {@link JobRow}s. Server-safe (no client hooks) so it renders both as
|
||||
* the static Suspense fallback and inside the client {@link JobBoard}.
|
||||
*/
|
||||
export function JobGroups({ groups, filtersActive = false }: JobGroupsProps) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<p className='py-10 text-[var(--text-muted)] text-base'>
|
||||
{filtersActive ? NO_MATCHING_ROLES_MESSAGE : NO_OPEN_ROLES_MESSAGE}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-12'>
|
||||
{groups.map((group) => (
|
||||
<section
|
||||
key={group.department}
|
||||
aria-label={`${group.department} roles`}
|
||||
className='flex flex-col'
|
||||
>
|
||||
<h3 className='pb-2 font-medium text-[var(--text-muted)] text-sm'>{group.department}</h3>
|
||||
<ul className='flex flex-col'>
|
||||
{group.postings.map((posting) => (
|
||||
<li key={posting.id}>
|
||||
<JobRow posting={posting} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JobRowProps {
|
||||
posting: CareerPosting
|
||||
}
|
||||
|
||||
/**
|
||||
* A single role row: title over a metadata line, with an "Apply" affordance that
|
||||
* links out to the posting on Ashby. The whole row is the link target; hovering
|
||||
* tints the row and advances the arrow. The metadata values are de-duplicated
|
||||
* because a remote posting normalizes both `location` and `workplaceType` to
|
||||
* "Remote", which would otherwise render "Remote · Remote" and collide as keys.
|
||||
*/
|
||||
function JobRow({ posting }: JobRowProps) {
|
||||
const meta = Array.from(
|
||||
new Set(
|
||||
[
|
||||
posting.location,
|
||||
posting.employmentType,
|
||||
posting.workplaceType,
|
||||
posting.compensationSummary,
|
||||
].filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={posting.jobUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={cn(
|
||||
'-mx-3 group flex items-center justify-between gap-6 rounded-lg border-[var(--border)]',
|
||||
'border-t px-3 py-5 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<h4 className='truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
{posting.title}
|
||||
</h4>
|
||||
<div className='flex flex-wrap items-center gap-x-2 gap-y-1 text-[var(--text-muted)] text-sm'>
|
||||
{meta.map((item, index) => (
|
||||
<span key={item} className='flex items-center gap-2'>
|
||||
{index > 0 && (
|
||||
<span aria-hidden className='text-[var(--text-muted)]'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className='flex shrink-0 items-center gap-1.5 font-medium text-[var(--text-body)] text-sm'>
|
||||
Apply
|
||||
<ArrowRight className='size-[14px] text-[var(--text-icon)] transition-transform group-hover:translate-x-0.5' />
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import { ALL_FILTER_VALUE } from '@/app/(landing)/careers/search-params'
|
||||
|
||||
export interface DepartmentGroup {
|
||||
department: string
|
||||
postings: CareerPosting[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows postings to a selected Team and Location, treating {@link ALL_FILTER_VALUE}
|
||||
* as "any". Shared by the server-rendered fallback and the client board so a
|
||||
* deep-linked filter resolves to the exact same set on both sides.
|
||||
*/
|
||||
export function filterPostings(
|
||||
postings: CareerPosting[],
|
||||
team: string,
|
||||
location: string
|
||||
): CareerPosting[] {
|
||||
return postings.filter(
|
||||
(posting) =>
|
||||
(team === ALL_FILTER_VALUE || posting.department === team) &&
|
||||
(location === ALL_FILTER_VALUE || posting.location === location)
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether either the Team or Location filter is narrowing the board. */
|
||||
export function hasActiveFilters(team: string, location: string): boolean {
|
||||
return team !== ALL_FILTER_VALUE || location !== ALL_FILTER_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets postings by department, preserving their incoming order (the fetcher
|
||||
* pre-sorts by department then title). Shared by the interactive board and its
|
||||
* static Suspense fallback so the two can never render a different grouping.
|
||||
*/
|
||||
export function groupByDepartment(postings: CareerPosting[]): DepartmentGroup[] {
|
||||
const byDepartment = new Map<string, CareerPosting[]>()
|
||||
for (const posting of postings) {
|
||||
const bucket = byDepartment.get(posting.department)
|
||||
if (bucket) bucket.push(posting)
|
||||
else byDepartment.set(posting.department, [posting])
|
||||
}
|
||||
return Array.from(byDepartment, ([department, items]) => ({ department, postings: items }))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import { buildLandingMetadata, withFilteredNoindex } from '@/lib/landing/seo'
|
||||
import Careers from '@/app/(landing)/careers/careers'
|
||||
import { ALL_FILTER_VALUE, careersSearchParamsCache } from '@/app/(landing)/careers/search-params'
|
||||
|
||||
/**
|
||||
* `team`/`location` render a genuinely different server-rendered job list (see
|
||||
* search-params.ts), so filtered URLs are noindexed rather than
|
||||
* self-canonicalized — same policy as the integrations/models/blog catalogs.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<Metadata> {
|
||||
const { team, location } = await careersSearchParamsCache.parse(searchParams)
|
||||
const isFiltered = team !== ALL_FILTER_VALUE || location !== ALL_FILTER_VALUE
|
||||
|
||||
const base = buildLandingMetadata({
|
||||
title: 'Careers | Sim, the AI Workspace',
|
||||
description:
|
||||
'Join Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. See open engineering, design, and go-to-market roles.',
|
||||
path: '/careers',
|
||||
keywords:
|
||||
'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs',
|
||||
})
|
||||
|
||||
return withFilteredNoindex(base, isFiltered)
|
||||
}
|
||||
|
||||
export default function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
return <Careers searchParams={searchParams} />
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
|
||||
|
||||
/**
|
||||
* Sentinel value for an inactive filter — matches every posting. Namespaced with
|
||||
* underscores so it can never collide with a real Ashby department or location
|
||||
* value (e.g. a team literally named "all").
|
||||
*/
|
||||
export const ALL_FILTER_VALUE = '__all__'
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query params for the careers job board's Team and
|
||||
* Location filters. Shareable, deep-linkable view-state over an already-rendered
|
||||
* list, so it lives in the URL (nuqs) — never in a store. The values are dynamic
|
||||
* (departments/locations come from the live board), so plain string parsers with
|
||||
* an `all` sentinel default rather than a fixed literal set.
|
||||
*/
|
||||
export const careersParsers = {
|
||||
team: parseAsString.withDefault(ALL_FILTER_VALUE),
|
||||
location: parseAsString.withDefault(ALL_FILTER_VALUE),
|
||||
} as const
|
||||
|
||||
/** Clean URLs, no back-stack churn — the filters are a passive view switch. */
|
||||
export const careersUrlKeys = {
|
||||
history: 'replace',
|
||||
shallow: true,
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Server-side reader for the same parser map. The page parses the request's
|
||||
* query with this so the statically-rendered fallback is filtered to match a
|
||||
* deep-linked `?team=`/`?location=` URL — the roles never flash unfiltered before
|
||||
* the client board hydrates.
|
||||
*/
|
||||
export const careersSearchParamsCache = createSearchParamsCache(careersParsers)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ChangelogActions, ChangelogTimeline } from '@/app/(landing)/changelog/components'
|
||||
import type { ChangelogEntry, GitHubRelease } from '@/app/(landing)/changelog/types'
|
||||
import { mapReleases, releasesEndpoint } from '@/app/(landing)/changelog/utils'
|
||||
import { ProseHero, ProseShell } from '@/app/(landing)/components/prose-page'
|
||||
|
||||
const logger = createLogger('Changelog')
|
||||
|
||||
/**
|
||||
* Changelog page - reuses the shared prose primitives ({@link ProseShell} +
|
||||
* {@link ProseHero}) so its headline and column match Terms and Privacy, then
|
||||
* renders the GitHub-release timeline. The first page of releases is
|
||||
* fetched here on the server at build/revalidate time; the {@link ChangelogTimeline}
|
||||
* client leaf paginates the rest. Re-authored from the prior dark changelog onto
|
||||
* the platform light tokens.
|
||||
*/
|
||||
|
||||
const LEAD =
|
||||
'Every new feature, improvement, and fix in Sim, the open-source AI workspace, with release notes straight from GitHub.'
|
||||
|
||||
async function getInitialEntries(): Promise<ChangelogEntry[]> {
|
||||
try {
|
||||
// boundary-raw-fetch: external GitHub Releases API (cross-origin), not a same-origin contract
|
||||
const res = await fetch(releasesEndpoint(1), {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
const releases = (await res.json()) as GitHubRelease[]
|
||||
return mapReleases(releases ?? [])
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load initial changelog releases from GitHub', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Changelog() {
|
||||
const entries = await getInitialEntries()
|
||||
|
||||
return (
|
||||
<ProseShell>
|
||||
<ProseHero title='Changelog' lead={LEAD} actions={<ChangelogActions />} />
|
||||
<section id='releases' aria-label='Release history'>
|
||||
<ChangelogTimeline initialEntries={entries} />
|
||||
</section>
|
||||
</ProseShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import { BookOpen, Github, Rss } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Changelog hero actions - the GitHub / Documentation / RSS pill links shown
|
||||
* beneath the changelog headline. A small client leaf because `ChipLink` is a
|
||||
* Client Component and its `leftIcon` is a component reference that cannot cross
|
||||
* the server→client boundary as a prop (same pattern as the platform pill CTA).
|
||||
* GitHub is the primary filled chip; Docs and RSS are the default pills.
|
||||
*/
|
||||
export function ChangelogActions() {
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-1'>
|
||||
<ChipLink
|
||||
variant='primary'
|
||||
href='https://github.com/simstudioai/sim/releases'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
leftIcon={Github}
|
||||
>
|
||||
View on GitHub
|
||||
</ChipLink>
|
||||
<ChipLink
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
leftIcon={BookOpen}
|
||||
>
|
||||
Documentation
|
||||
</ChipLink>
|
||||
<ChipLink href='/changelog.xml' leftIcon={Rss}>
|
||||
RSS Feed
|
||||
</ChipLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChangelogActions } from './changelog-actions'
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useRef, useState } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { Avatar, AvatarFallback, AvatarImage, Chip, cn } from '@sim/emcn'
|
||||
import { formatDate } from '@sim/utils/formatting'
|
||||
import type { ChangelogEntry, GitHubRelease } from '@/app/(landing)/changelog/types'
|
||||
import { mapReleases, releasesEndpoint } from '@/app/(landing)/changelog/utils'
|
||||
|
||||
/**
|
||||
* The changelog timeline - the single client leaf of the changelog page. Renders
|
||||
* each GitHub release as a `<section>` (an `<h2>` version tag + contributor
|
||||
* avatars + cleaned markdown via {@link Streamdown}) and paginates further pages
|
||||
* from the GitHub Releases API on demand. Re-authored from the prior dark
|
||||
* timeline onto the platform light tokens; the fetch, markdown cleaning, and
|
||||
* load-more behavior are preserved.
|
||||
*/
|
||||
|
||||
interface ChangelogTimelineProps {
|
||||
initialEntries: ChangelogEntry[]
|
||||
}
|
||||
|
||||
function stripContributors(body: string): string {
|
||||
let output = body
|
||||
output = output.replace(
|
||||
/(^|\n)#{1,6}\s*Contributors\s*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi,
|
||||
'\n'
|
||||
)
|
||||
output = output.replace(
|
||||
/(^|\n)\s*(?:\*\*|__)?\s*Contributors\s*(?:\*\*|__)?\s*:?\s*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi,
|
||||
'\n'
|
||||
)
|
||||
output = output.replace(
|
||||
/(^|\n)[-*+]\s*(?:@[A-Za-z0-9-]+(?:\s*,\s*|\s+))+@[A-Za-z0-9-]+\s*(?=\n)/g,
|
||||
'\n'
|
||||
)
|
||||
output = output.replace(
|
||||
/(^|\n)\s*(?:@[A-Za-z0-9-]+(?:\s*,\s*|\s+))+@[A-Za-z0-9-]+\s*(?=\n)/g,
|
||||
'\n'
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
function stripPrReferences(body: string): string {
|
||||
return body.replace(/\s*\(\s*\[#\d+\]\([^)]*\)\s*\)/g, '').replace(/\s*\(\s*#\d+\s*\)/g, '')
|
||||
}
|
||||
|
||||
function cleanMarkdown(body: string): string {
|
||||
return stripPrReferences(stripContributors(body))
|
||||
}
|
||||
|
||||
function isContributorsLabel(children: ReactNode): boolean {
|
||||
return /^\s*contributors\s*:?\s*$/i.test(String(children))
|
||||
}
|
||||
|
||||
export function ChangelogTimeline({ initialEntries }: ChangelogTimelineProps) {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[]>(initialEntries)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [done, setDone] = useState<boolean>(false)
|
||||
const pageRef = useRef(1)
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || done) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const nextPage = pageRef.current + 1
|
||||
// boundary-raw-fetch: external GitHub Releases API (cross-origin), not a same-origin contract
|
||||
const res = await fetch(releasesEndpoint(nextPage), {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
})
|
||||
const releases = (await res.json()) as GitHubRelease[]
|
||||
const mapped = mapReleases(releases ?? [])
|
||||
|
||||
if (mapped.length === 0) {
|
||||
setDone(true)
|
||||
} else {
|
||||
setEntries((prev) => [...prev, ...mapped])
|
||||
pageRef.current = nextPage
|
||||
}
|
||||
} catch {
|
||||
setDone(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-7'>
|
||||
{entries.map((entry) => {
|
||||
const headingId = `release-${entry.tag}-heading`
|
||||
return (
|
||||
<section key={entry.tag} aria-labelledby={headingId} className='flex flex-col'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h2 id={headingId} className='text-[18px] text-[var(--text-primary)] leading-[1.3]'>
|
||||
{entry.tag}
|
||||
</h2>
|
||||
{entry.contributors.length > 0 ? (
|
||||
<div className='flex'>
|
||||
{entry.contributors.slice(0, 5).map((contributor, index) => (
|
||||
<a
|
||||
key={contributor}
|
||||
href={`https://github.com/${contributor}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label={`View @${contributor} on GitHub`}
|
||||
title={`@${contributor}`}
|
||||
className={index === 0 ? 'block' : '-ms-2 block'}
|
||||
>
|
||||
<Avatar className='size-6 ring-2 ring-[var(--bg)]'>
|
||||
<AvatarImage
|
||||
src={`https://avatars.githubusercontent.com/${contributor}`}
|
||||
alt={`@${contributor}`}
|
||||
className='hover:z-10'
|
||||
/>
|
||||
<AvatarFallback>{contributor.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
))}
|
||||
{entry.contributors.length > 5 ? (
|
||||
<div className='-ms-2 relative flex size-6 items-center justify-center rounded-full bg-[var(--surface-2)] text-[10px] text-[var(--text-body)] ring-2 ring-[var(--bg)] hover:z-10'>
|
||||
+{entry.contributors.length - 5}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
{formatDate(new Date(entry.date))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div aria-hidden='true' className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
|
||||
|
||||
<div className='max-w-none'>
|
||||
<Streamdown
|
||||
mode='static'
|
||||
components={{
|
||||
h2: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h3
|
||||
className='mt-5 mb-2 text-[14px] text-[var(--text-primary)] leading-[1.4]'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h3: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h4
|
||||
className='mt-4 mb-1 text-[14px] text-[var(--text-primary)] leading-[1.4]'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul className='mt-2 mb-3 space-y-1.5' {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
li: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<li
|
||||
className='text-[13px] text-[var(--text-body)] leading-relaxed'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
p: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<p
|
||||
className='mb-3 text-[13px] text-[var(--text-body)] leading-relaxed'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className='text-[var(--text-primary)]' {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
inlineCode: ({ children }) => (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-2)] px-1 py-0.5 font-mono text-[var(--text-primary)] not-italic'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
img: () => null,
|
||||
a: ({ children, className, ...props }) => (
|
||||
<a
|
||||
{...props}
|
||||
className={cn('text-[var(--text-primary)] underline', className)}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{cleanMarkdown(entry.content)}
|
||||
</Streamdown>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
{!done ? (
|
||||
<div>
|
||||
<Chip type='button' flush onClick={loadMore} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Show more'}
|
||||
</Chip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChangelogTimeline } from './changelog-timeline'
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ChangelogActions } from './changelog-actions'
|
||||
export { ChangelogTimeline } from './changelog-timeline'
|
||||
@@ -0,0 +1,18 @@
|
||||
import { buildLandingMetadata } from '@/lib/landing/seo'
|
||||
import Changelog from '@/app/(landing)/changelog/changelog'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
const TITLE = 'Changelog | Sim, the AI Workspace'
|
||||
const DESCRIPTION =
|
||||
'Every new feature, improvement, and fix in Sim, the open-source AI workspace, with release notes straight from GitHub.'
|
||||
|
||||
export const metadata = buildLandingMetadata({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: '/changelog',
|
||||
})
|
||||
|
||||
export default function Page() {
|
||||
return <Changelog />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Changelog types shared between the server page (initial fetch) and the client
|
||||
* timeline (load-more). {@link GitHubRelease} is the minimal shape we read from
|
||||
* the GitHub Releases API; {@link ChangelogEntry} is the normalized entry the UI
|
||||
* renders.
|
||||
*/
|
||||
|
||||
/** The minimal subset of a GitHub Releases API item that the changelog reads. */
|
||||
export interface GitHubRelease {
|
||||
tag_name: string
|
||||
name: string | null
|
||||
body: string | null
|
||||
published_at: string
|
||||
html_url: string
|
||||
prerelease: boolean
|
||||
}
|
||||
|
||||
/** A normalized changelog entry rendered in the timeline. */
|
||||
export interface ChangelogEntry {
|
||||
tag: string
|
||||
title: string
|
||||
content: string
|
||||
date: string
|
||||
url: string
|
||||
contributors: string[]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ChangelogEntry, GitHubRelease } from '@/app/(landing)/changelog/types'
|
||||
|
||||
/**
|
||||
* Changelog helpers shared by the server page (initial page) and the client
|
||||
* timeline (subsequent pages), so the GitHub-release → entry mapping is defined
|
||||
* once and both surfaces stay in sync.
|
||||
*/
|
||||
|
||||
/** How many releases to request per GitHub API page. */
|
||||
export const RELEASES_PER_PAGE = 10
|
||||
|
||||
/** Builds the GitHub Releases endpoint for a given 1-based page. */
|
||||
export function releasesEndpoint(page: number): string {
|
||||
return `https://api.github.com/repos/simstudioai/sim/releases?per_page=${RELEASES_PER_PAGE}&page=${page}`
|
||||
}
|
||||
|
||||
/** Removes literal ` ` artifacts from release bodies. */
|
||||
export function sanitizeContent(body: string): string {
|
||||
return body.replace(/ /g, '')
|
||||
}
|
||||
|
||||
/** Extracts unique `@handle` GitHub mentions from a release body. */
|
||||
export function extractMentions(body: string): string[] {
|
||||
const matches = body.match(/@([A-Za-z0-9-]+)/g) ?? []
|
||||
return Array.from(new Set(matches.map((mention) => mention.slice(1))))
|
||||
}
|
||||
|
||||
/** Maps non-prerelease GitHub releases to normalized {@link ChangelogEntry} items. */
|
||||
export function mapReleases(releases: GitHubRelease[]): ChangelogEntry[] {
|
||||
return releases.reduce<ChangelogEntry[]>((acc, release) => {
|
||||
if (release.prerelease) {
|
||||
return acc
|
||||
}
|
||||
|
||||
const body = String(release.body ?? '')
|
||||
acc.push({
|
||||
tag: release.tag_name,
|
||||
title: release.name || release.tag_name,
|
||||
content: sanitizeContent(body),
|
||||
date: release.published_at,
|
||||
url: release.html_url,
|
||||
contributors: extractMentions(body),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { CompetitorProfile } from '@/lib/compare/data'
|
||||
import { simProfile } from '@/lib/compare/data'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { buildLandingMetadata } from '@/lib/landing/seo'
|
||||
import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections'
|
||||
import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile'
|
||||
import { ComparisonCards } from '@/app/(landing)/comparison/components/comparison-cards'
|
||||
import { ComparisonTable } from '@/app/(landing)/comparison/components/comparison-table'
|
||||
import {
|
||||
ALL_COMPETITORS,
|
||||
buildBottomLine,
|
||||
buildComparisonFaqs,
|
||||
getCompetitorBySlug,
|
||||
getLatestVerifiedDate,
|
||||
SIM_LATEST_VERIFIED,
|
||||
} from '@/app/(landing)/comparison/utils'
|
||||
import { BackLink } from '@/app/(landing)/components'
|
||||
import { Cta } from '@/app/(landing)/components/cta/cta'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
|
||||
const baseUrl = SITE_URL
|
||||
|
||||
export const revalidate = 3600
|
||||
export const dynamicParams = false
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return ALL_COMPETITORS.map((competitor) => ({ provider: competitor.id }))
|
||||
}
|
||||
|
||||
/** Flattens a profile's facts into JSON-LD `additionalProperty` entries, in {@link COMPARISON_SECTIONS} order. */
|
||||
function factsToProperties(profile: CompetitorProfile) {
|
||||
return COMPARISON_SECTIONS.flatMap((section) => {
|
||||
const group = getFactGroup(profile, section.group)
|
||||
return section.rows.map((row) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: row.label,
|
||||
value: group[row.key]?.value ?? 'Unknown',
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { provider: providerSlug } = await params
|
||||
const competitor = getCompetitorBySlug(providerSlug)
|
||||
|
||||
if (!competitor) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return buildLandingMetadata({
|
||||
title: `Sim vs ${competitor.name} | Sim, the AI Workspace`,
|
||||
description: `Compare Sim, the open-source AI workspace, to ${competitor.name} on platform, AI, integrations, pricing, security, and support. Sourced and dated facts.`,
|
||||
path: `/comparison/${competitor.id}`,
|
||||
keywords: [
|
||||
`Sim vs ${competitor.name}`,
|
||||
`${competitor.name} alternative`,
|
||||
`${competitor.name} vs Sim`,
|
||||
`open source ${competitor.name} alternative`,
|
||||
`${competitor.name} comparison`,
|
||||
'AI agent workspace',
|
||||
'AI workflow automation comparison',
|
||||
].join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
export default async function ComparisonProviderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}) {
|
||||
const { provider: providerSlug } = await params
|
||||
const competitor = getCompetitorBySlug(providerSlug)
|
||||
|
||||
if (!competitor) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const faqs = buildComparisonFaqs(competitor)
|
||||
const verdict = buildBottomLine(competitor)
|
||||
const CompetitorIcon = competitor.brand?.icon
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Comparison', item: `${baseUrl}/comparison` },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: `Sim vs ${competitor.name}`,
|
||||
item: `${baseUrl}/comparison/${competitor.id}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const latestVerified = new Date(
|
||||
Math.max(SIM_LATEST_VERIFIED.getTime(), getLatestVerifiedDate(competitor).getTime())
|
||||
)
|
||||
|
||||
const productComparisonJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `Sim vs ${competitor.name}`,
|
||||
description: `Feature and pricing comparison between Sim and ${competitor.name}.`,
|
||||
url: `${baseUrl}/comparison/${competitor.id}`,
|
||||
dateModified: latestVerified.toISOString().slice(0, 10),
|
||||
numberOfItems: 2,
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
item: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Sim',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
url: SITE_URL,
|
||||
description: simProfile.oneLiner,
|
||||
additionalProperty: factsToProperties(simProfile),
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
item: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: competitor.name,
|
||||
applicationCategory: 'BusinessApplication',
|
||||
url: competitor.website,
|
||||
description: competitor.oneLiner,
|
||||
additionalProperty: factsToProperties(competitor),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<JsonLd data={productComparisonJsonLd} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
|
||||
<main id='main-content' className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1446px] px-12 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
<div className='mb-6'>
|
||||
<BackLink href='/comparison' label='Back to comparisons' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<h1
|
||||
id='comparison-heading'
|
||||
className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Sim vs {competitor.name}
|
||||
</h1>
|
||||
<p className='max-w-[720px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents
|
||||
visually, conversationally, or with code. Here is how Sim compares to{' '}
|
||||
{competitor.name} on platform architecture, AI capabilities, integrations, pricing,
|
||||
security, and support. Every fact below is sourced and dated.
|
||||
</p>
|
||||
<p className='sr-only'>
|
||||
Sim is an open-source AI workspace for building, deploying, and managing AI agents.
|
||||
This page compares Sim to {competitor.name} across platform architecture, AI
|
||||
capabilities, integrations, pricing, security and compliance, observability, and
|
||||
support, using sourced, dated facts for buyers evaluating both platforms.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1446px]'>
|
||||
<div className='mx-12 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2'>
|
||||
<section
|
||||
aria-labelledby='what-is-sim-heading'
|
||||
className='border-[var(--border)] border-r px-6 py-6 max-sm:border-r-0 max-sm:border-b'
|
||||
>
|
||||
<h2
|
||||
id='what-is-sim-heading'
|
||||
className='mb-2 flex items-center gap-2.5 text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
<SimIconTile className='size-9' />
|
||||
What is Sim?
|
||||
</h2>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{simProfile.oneLiner}
|
||||
</p>
|
||||
</section>
|
||||
<section aria-labelledby='what-is-competitor-heading' className='px-6 py-6'>
|
||||
<h2
|
||||
id='what-is-competitor-heading'
|
||||
className='mb-2 flex items-center gap-2.5 text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
{CompetitorIcon ? (
|
||||
<BrandIconTile
|
||||
icon={CompetitorIcon}
|
||||
selfFramed={competitor.brand?.selfFramed}
|
||||
className='size-9'
|
||||
iconClassName='size-5'
|
||||
/>
|
||||
) : null}
|
||||
What is {competitor.name}?
|
||||
</h2>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{competitor.oneLiner}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='comparison-table-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='comparison-table-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Sim vs {competitor.name}: feature-by-feature comparison
|
||||
</h2>
|
||||
<ComparisonTable sim={simProfile} competitor={competitor} />
|
||||
</section>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2'>
|
||||
<section
|
||||
aria-labelledby='sim-standout-heading'
|
||||
className='border-[var(--border)] border-r max-lg:border-r-0 max-lg:border-b'
|
||||
>
|
||||
<div className='px-6 pt-6 pb-2'>
|
||||
<h2
|
||||
id='sim-standout-heading'
|
||||
className='text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
Sim standout features
|
||||
</h2>
|
||||
</div>
|
||||
<ComparisonCards items={simProfile.standoutFeatures} />
|
||||
</section>
|
||||
<section aria-labelledby='competitor-limitations-heading'>
|
||||
<div className='px-6 pt-6 pb-2'>
|
||||
<h2
|
||||
id='competitor-limitations-heading'
|
||||
className='text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
Documented {competitor.name} limitations
|
||||
</h2>
|
||||
</div>
|
||||
<ComparisonCards items={competitor.limitations} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='bottom-line-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='bottom-line-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Bottom line
|
||||
</h2>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{verdict.chooseSim}
|
||||
</p>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{verdict.chooseCompetitor}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='faq-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='faq-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div>
|
||||
<LandingFAQ faqs={faqs} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</main>
|
||||
|
||||
<div className='py-16'>
|
||||
<Cta />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ComparisonFacts, CompetitorProfile, Fact } from '@/lib/compare/data'
|
||||
|
||||
/**
|
||||
* The one place a {@link ComparisonFacts} group is read back out of a profile
|
||||
* by group key. Every render-side consumer (the table, key-differences strip,
|
||||
* JSON-LD builder) needs this same lookup; centralizing it here means there is
|
||||
* exactly one cast to reason about instead of one per call site.
|
||||
*/
|
||||
export function getFactGroup<G extends keyof ComparisonFacts>(
|
||||
profile: CompetitorProfile,
|
||||
group: G
|
||||
): Record<string, Fact> {
|
||||
return profile.facts[group] as Record<string, Fact>
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in a comparison table section. Maps a human label to a fact key
|
||||
* within a {@link ComparisonFacts} group. `key` is intentionally `string`
|
||||
* (rather than a per-group `keyof` union) so a single array can hold rows
|
||||
* for every group without TypeScript collapsing the distributed generic to
|
||||
* `never`; correctness is enforced once, by construction, in
|
||||
* {@link COMPARISON_SECTIONS} below, and the renderer reads through it.
|
||||
*/
|
||||
export interface ComparisonRowDef {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One section of the comparison table, mirroring a {@link ComparisonFacts} group. */
|
||||
export interface ComparisonSectionDef {
|
||||
group: keyof ComparisonFacts
|
||||
title: string
|
||||
rows: ComparisonRowDef[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-checks a section's rows against its own group's actual fact keys
|
||||
* (via the per-call generic `G`), then widens to the plain `ComparisonRowDef`
|
||||
* shape used by {@link COMPARISON_SECTIONS}. This is where row-key
|
||||
* correctness is actually enforced. A typo here fails the build.
|
||||
*/
|
||||
function defineSection<G extends keyof ComparisonFacts>(section: {
|
||||
group: G
|
||||
title: string
|
||||
rows: Array<{ key: keyof ComparisonFacts[G]; label: string }>
|
||||
}): ComparisonSectionDef {
|
||||
return section as ComparisonSectionDef
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical section/row order for rendering a {@link ComparisonFacts} profile
|
||||
* pair as a table. Single source of truth for row labels. Add a field here
|
||||
* once and every comparison page picks it up.
|
||||
*/
|
||||
export const COMPARISON_SECTIONS: ComparisonSectionDef[] = [
|
||||
defineSection({
|
||||
group: 'platform',
|
||||
title: 'Platform',
|
||||
rows: [
|
||||
{ key: 'builderType', label: 'Builder type' },
|
||||
{ key: 'learningCurve', label: 'Learning curve' },
|
||||
{ key: 'selfHostOption', label: 'Self-hosting' },
|
||||
{ key: 'deploymentOptions', label: 'Deployment options' },
|
||||
{ key: 'templates', label: 'Templates' },
|
||||
{ key: 'license', label: 'License' },
|
||||
{ key: 'environmentPromotion', label: 'Environment promotion' },
|
||||
{ key: 'versionControlDepth', label: 'Version control' },
|
||||
{ key: 'realtimeCollaboration', label: 'Realtime collaboration' },
|
||||
{ key: 'nativeFileStorage', label: 'Native file storage' },
|
||||
{ key: 'dataTables', label: 'Native data tables' },
|
||||
{ key: 'richTextEditor', label: 'Rich-text document editor' },
|
||||
{ key: 'subWorkflows', label: 'Sub-workflows (composition)' },
|
||||
{ key: 'customBlocks', label: 'Custom blocks (org-wide reuse)' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'pricing',
|
||||
title: 'Pricing',
|
||||
rows: [
|
||||
{ key: 'pricingModel', label: 'Pricing model' },
|
||||
{ key: 'entryPaidPlan', label: 'Entry paid plan' },
|
||||
{ key: 'freeTier', label: 'Free tier' },
|
||||
{ key: 'byok', label: 'Bring your own key' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'security',
|
||||
title: 'Security & compliance',
|
||||
rows: [
|
||||
{ key: 'soc2', label: 'SOC 2' },
|
||||
{ key: 'dataResidency', label: 'Data residency' },
|
||||
{ key: 'rbac', label: 'Role-based access control' },
|
||||
{ key: 'auditLogging', label: 'Audit logging' },
|
||||
{ key: 'additionalCompliance', label: 'Additional compliance' },
|
||||
{ key: 'modelAndToolGovernance', label: 'Model & tool governance' },
|
||||
{ key: 'credentialGovernance', label: 'Credential governance' },
|
||||
{ key: 'sso', label: 'Single sign-on (SSO)' },
|
||||
{ key: 'thirdPartyVetting', label: 'Vetted first-party integrations' },
|
||||
{ key: 'piiRedaction', label: 'PII redaction' },
|
||||
{ key: 'dataRetention', label: 'Custom data retention' },
|
||||
{ key: 'whiteLabeling', label: 'White-labeling' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'aiCapabilities',
|
||||
title: 'AI capabilities',
|
||||
rows: [
|
||||
{ key: 'multiLlmSupport', label: 'Multi-LLM support' },
|
||||
{ key: 'agentReasoningBlocks', label: 'Agent reasoning blocks' },
|
||||
{ key: 'naturalLanguageBuilding', label: 'Natural-language building' },
|
||||
{ key: 'knowledgeBaseRag', label: 'Knowledge base / RAG' },
|
||||
{ key: 'mcpSupport', label: 'MCP support' },
|
||||
{ key: 'evaluationGuardrails', label: 'Evaluation & guardrails' },
|
||||
{ key: 'humanInTheLoop', label: 'Human-in-the-loop' },
|
||||
{ key: 'generativeMedia', label: 'Generative media' },
|
||||
{ key: 'dynamicToolUse', label: 'Dynamic tool use' },
|
||||
{ key: 'modelFallback', label: 'Automatic model fallback' },
|
||||
{ key: 'agentSkills', label: 'Agent skills' },
|
||||
{ key: 'nativeChatDeployment', label: 'Native chat deployment' },
|
||||
{ key: 'parallelExecution', label: 'Parallel execution' },
|
||||
{ key: 'a2aProtocol', label: 'Agent2Agent (A2A) protocol' },
|
||||
{ key: 'loopIteration', label: 'Loop / iteration block' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'integrations',
|
||||
title: 'Integrations',
|
||||
rows: [
|
||||
{ key: 'integrationCount', label: 'Integrations' },
|
||||
{ key: 'triggerTypes', label: 'Trigger types' },
|
||||
{ key: 'customCodeSteps', label: 'Custom code steps' },
|
||||
{ key: 'apiPublishing', label: 'API publishing' },
|
||||
{ key: 'extensibilitySdk', label: 'SDKs & extensibility' },
|
||||
{ key: 'mcpPublishing', label: 'Publish as MCP server' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'observability',
|
||||
title: 'Observability & durability',
|
||||
rows: [
|
||||
{ key: 'tracingDepth', label: 'Tracing & observability' },
|
||||
{ key: 'durabilityModel', label: 'Durability & retries' },
|
||||
{ key: 'failureAlerting', label: 'Failure alerting' },
|
||||
{ key: 'dataDrains', label: 'Data drains' },
|
||||
{ key: 'asyncExecution', label: 'Async execution' },
|
||||
{ key: 'executionLimits', label: 'Execution limits' },
|
||||
{ key: 'partialFailureHandling', label: 'Partial-failure handling' },
|
||||
{ key: 'unattendedExecution', label: 'Unattended execution' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'support',
|
||||
title: 'Support',
|
||||
rows: [
|
||||
{ key: 'supportChannels', label: 'Support channels' },
|
||||
{ key: 'sla', label: 'SLA' },
|
||||
{ key: 'community', label: 'Community' },
|
||||
{ key: 'academy', label: 'Academy / training' },
|
||||
],
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { CompetitorBrand } from '@/lib/compare/data'
|
||||
import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark'
|
||||
|
||||
export interface BrandIconTileProps {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
/**
|
||||
* Whether `icon` already renders a full, self-contained brand-colored
|
||||
* square (e.g. a fetched app-store-style logo) rather than a bare
|
||||
* transparent glyph. See {@link CompetitorBrand.selfFramed}.
|
||||
*/
|
||||
selfFramed?: boolean
|
||||
/** Outer tile size, e.g. `size-8`. Defaults to the integrations-page card size. */
|
||||
className?: string
|
||||
/** Icon glyph size inside the tile, e.g. `size-4`. Ignored when `selfFramed`. */
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A rounded, bordered icon tile matching the platform's app-icon chrome
|
||||
* conventions (border radius, border token, background), so competitor brand
|
||||
* logos read as the same first-class "app icon" chrome as the rest of the
|
||||
* product, instead of a bare, unframed SVG floating in the layout.
|
||||
*
|
||||
* A self-framed logo (already a complete brand-colored square) fills the
|
||||
* tile edge-to-edge, clipped to the same rounded corners. Otherwise the icon
|
||||
* is a small transparent glyph centered on a plain bordered background.
|
||||
*/
|
||||
export function BrandIconTile({
|
||||
icon: Icon,
|
||||
selfFramed = false,
|
||||
className = 'size-8',
|
||||
iconClassName = 'size-4',
|
||||
}: BrandIconTileProps) {
|
||||
if (selfFramed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 overflow-hidden rounded-xl border border-[var(--border-1)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className='size-full' aria-hidden='true' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={iconClassName} aria-hidden='true' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SimIconTileProps {
|
||||
/** Outer tile size, e.g. `size-8`. Defaults to the integrations-page card size. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rounded, bordered tile as {@link BrandIconTile}, but for Sim's own
|
||||
* wordmark. So "Sim" gets the identical icon-chip treatment as every
|
||||
* competitor it's compared against, instead of appearing as bare text.
|
||||
*/
|
||||
export function SimIconTile({ className = 'size-8' }: SimIconTileProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className='inline-flex scale-[0.6]'>
|
||||
<SimWordmark />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BrandIconTileProps, SimIconTileProps } from './brand-icon-tile'
|
||||
export { BrandIconTile, SimIconTile } from './brand-icon-tile'
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FactSource } from '@/lib/compare/data'
|
||||
import { SourceLink } from '@/app/(landing)/comparison/components/source-info'
|
||||
|
||||
interface ComparisonCardItem {
|
||||
title: string
|
||||
description: string
|
||||
shortDescription?: string
|
||||
source: FactSource
|
||||
}
|
||||
|
||||
export interface ComparisonCardsProps {
|
||||
items: ComparisonCardItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertically stacked list of atomic, independently quotable fact cards,
|
||||
* each self-contained title + a one-line `shortDescription` (falling back to
|
||||
* `description` if a short version hasn't been authored yet). Used for both
|
||||
* a competitor's standout features and its documented limitations.
|
||||
*
|
||||
* The full `description` is always present as `sr-only` text. Server
|
||||
* rendered regardless of hover/JS state. So an LLM or crawler reading the
|
||||
* page still gets the complete claim even though a human sees only the
|
||||
* one-line summary. Hovering the title itself (`SourceLink`) shows a short
|
||||
* "Source: X" tooltip and clicking it opens the source, rather than a
|
||||
* separate info-icon affordance next to every card.
|
||||
*/
|
||||
export function ComparisonCards({ items }: ComparisonCardsProps) {
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={index > 0 ? 'border-[var(--border)] border-t px-6 py-4' : 'px-6 py-4'}
|
||||
>
|
||||
<h3 className='mb-1 text-[var(--text-primary)] text-base leading-snug tracking-[-0.01em]'>
|
||||
<SourceLink source={item.source}>{item.title}</SourceLink>
|
||||
</h3>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{item.shortDescription ?? item.description}
|
||||
</p>
|
||||
{item.shortDescription ? <span className='sr-only'>{item.description}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ComparisonCardsProps } from './comparison-cards'
|
||||
export { ComparisonCards } from './comparison-cards'
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { CompetitorProfile } from '@/lib/compare/data'
|
||||
import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections'
|
||||
import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile'
|
||||
import { FactValue } from '@/app/(landing)/comparison/components/fact-value'
|
||||
|
||||
export interface ComparisonTableProps {
|
||||
sim: CompetitorProfile
|
||||
competitor: CompetitorProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the row-label column during horizontal scroll on genuinely spacious
|
||||
* viewports (the standard pattern for responsive data tables, e.g.
|
||||
* Stripe/GitHub/Notion comparison tables) so a reader keeps row context while
|
||||
* scrolling to see the Sim/competitor values. Below `lg` (this page's own
|
||||
* tablet-and-below tier, per `.claude/rules` for this route group) the table
|
||||
* switches to a stacked layout instead (see `MOBILE_STACK_*`) rather than
|
||||
* relying on horizontal scroll at a width too narrow to render a 3-column
|
||||
* table comfortably, so sticky positioning is scoped to `lg:` only. The
|
||||
* shadow is a permanent CSS-only affordance (no scroll-position JS) so this
|
||||
* stays a zero-hydration server component.
|
||||
*/
|
||||
const STICKY_LABEL_COL = 'lg:sticky lg:left-0 lg:z-10 lg:shadow-[2px_0_4px_-2px_rgba(0,0,0,0.08)]'
|
||||
|
||||
/**
|
||||
* Below `lg` (1024px) a 3-column grid doesn't reliably have room to be
|
||||
* legible, so each fact instead stacks as label -> Sim's value -> the
|
||||
* competitor's value, with a small name tag on each value since the column
|
||||
* headers are no longer directly above. Applied to the label cell.
|
||||
*/
|
||||
const MOBILE_STACK_LABEL = 'max-lg:border-r-0 max-lg:border-b-0 max-lg:pt-3 max-lg:pb-1'
|
||||
|
||||
/**
|
||||
* Applied to a value cell (Sim's or the competitor's) in the stacked mobile
|
||||
* layout. `items-stretch` overrides the cell's base `items-center` (which
|
||||
* would otherwise shrink-wrap and center each child horizontally in a
|
||||
* flex-col): stretching gives the name tag and the value their own
|
||||
* full-width box to left-align and truncate within, instead of a
|
||||
* content-sized box with no boundary to clip against.
|
||||
*/
|
||||
const MOBILE_STACK_VALUE =
|
||||
'max-lg:flex-col max-lg:items-stretch max-lg:gap-0.5 max-lg:border-r-0 max-lg:px-4'
|
||||
|
||||
function ColumnHeader({
|
||||
name,
|
||||
iconTile,
|
||||
isSim,
|
||||
}: {
|
||||
name: string
|
||||
iconTile: ReactNode
|
||||
isSim: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col items-center gap-2 border-[var(--border-1)] border-b px-3 py-4 text-center',
|
||||
isSim ? 'bg-[var(--surface-2)]' : 'bg-[var(--surface-1)]'
|
||||
)}
|
||||
>
|
||||
{iconTile}
|
||||
<span className='w-full truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column "Sim vs {Competitor}" fact table, styled after the billing
|
||||
* upgrade-page comparison table (same border/hairline rhythm and section
|
||||
* headers) but data-driven off {@link CompetitorProfile.facts} instead of the
|
||||
* fixed 4-tier plan schema. Data cells share one neutral surface for both
|
||||
* columns. The Sim column is called out only in the header row (a bottom
|
||||
* accent border), so the table reads as one clean grid rather than a
|
||||
* checkerboard. Pure server component: every value is plain server-rendered
|
||||
* text so crawlers and AI answer engines read the full comparison without
|
||||
* any client-side hydration.
|
||||
*/
|
||||
export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
|
||||
return (
|
||||
<div className='w-full overflow-x-auto rounded-xl border border-[var(--border-1)]'>
|
||||
<div
|
||||
role='table'
|
||||
aria-label={`Sim vs ${competitor.name} feature comparison`}
|
||||
className='grid grid-cols-1 lg:min-w-[560px] lg:grid-cols-[minmax(140px,max-content)_1fr_1fr]'
|
||||
>
|
||||
<div className='contents' role='row'>
|
||||
<div
|
||||
role='columnheader'
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col justify-center border-[var(--border)] border-r border-b bg-[var(--surface-1)] px-4 py-4',
|
||||
STICKY_LABEL_COL,
|
||||
'max-lg:border-r-0'
|
||||
)}
|
||||
>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
Compare
|
||||
</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-small'>
|
||||
{sim.name} vs {competitor.name}
|
||||
</span>
|
||||
</div>
|
||||
<ColumnHeader name={sim.name} iconTile={<SimIconTile className='size-9' />} isSim />
|
||||
<ColumnHeader
|
||||
name={competitor.name}
|
||||
iconTile={
|
||||
competitor.brand?.icon ? (
|
||||
<BrandIconTile
|
||||
icon={competitor.brand.icon}
|
||||
selfFramed={competitor.brand.selfFramed}
|
||||
className='size-9'
|
||||
iconClassName='size-5'
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
isSim={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{COMPARISON_SECTIONS.map((section, sectionIdx) => {
|
||||
const simGroupFacts = getFactGroup(sim, section.group)
|
||||
const competitorGroupFacts = getFactGroup(competitor, section.group)
|
||||
|
||||
return (
|
||||
<div key={section.title} className='contents'>
|
||||
<div className='contents' role='row'>
|
||||
<div
|
||||
role='columnheader'
|
||||
className={cn(
|
||||
'border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2',
|
||||
STICKY_LABEL_COL,
|
||||
'max-lg:border-r-0',
|
||||
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-primary)] text-small'>
|
||||
{section.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role='presentation'
|
||||
className={cn(
|
||||
'col-span-2 bg-[var(--surface-1)] max-lg:hidden',
|
||||
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{section.rows.map((row, rowIdx) => {
|
||||
const simFact = simGroupFacts[row.key]
|
||||
const competitorFact = competitorGroupFacts[row.key]
|
||||
const isNotLastRow = rowIdx < section.rows.length - 1
|
||||
|
||||
return (
|
||||
<div key={row.key} className='contents' role='row'>
|
||||
<div
|
||||
role='rowheader'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2.5',
|
||||
STICKY_LABEL_COL,
|
||||
MOBILE_STACK_LABEL,
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='text-[var(--text-body)] text-small max-lg:font-medium max-lg:text-[var(--text-primary)]'>
|
||||
{row.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role='cell'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1 border-[var(--border)] border-r bg-[var(--surface-2)] px-3 py-2.5',
|
||||
MOBILE_STACK_VALUE,
|
||||
'max-lg:border-b-0 max-lg:pt-1 max-lg:pb-1',
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-muted)] text-caption lg:hidden'>
|
||||
{sim.name}
|
||||
</span>
|
||||
<FactValue fact={simFact} />
|
||||
</div>
|
||||
<div
|
||||
role='cell'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1 bg-[var(--surface-2)] px-3 py-2.5',
|
||||
MOBILE_STACK_VALUE,
|
||||
'max-lg:pt-1 max-lg:pb-3',
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-muted)] text-caption lg:hidden'>
|
||||
{competitor.name}
|
||||
</span>
|
||||
<FactValue fact={competitorFact} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ComparisonTableProps } from './comparison-table'
|
||||
export { ComparisonTable } from './comparison-table'
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Check, X } from '@sim/emcn/icons'
|
||||
import type { Fact } from '@/lib/compare/data'
|
||||
import { SourceLink } from '@/app/(landing)/comparison/components/source-info'
|
||||
import { parseFactValue } from '@/app/(landing)/comparison/fact-status'
|
||||
|
||||
export interface FactValueProps {
|
||||
fact: Fact
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one {@link Fact} for a glancing reader while keeping the full
|
||||
* granular fact server-rendered for crawlers and AI answer engines.
|
||||
*
|
||||
* - A true "Yes"/"No" fact renders as an icon alone (a monochrome check or
|
||||
* muted cross, no colored pass/fail styling), no visible text, since the
|
||||
* label column and surrounding context already say what's being asked.
|
||||
* - Any other fact shows its `shortValue` (a compact, pre-authored
|
||||
* restatement of `value`), never the full sentence.
|
||||
* - `Tooltip` here is a cursor-following mini-bubble meant for a short
|
||||
* one-line label (see its own docs/usages: "Refresh", "last updated: X")
|
||||
* . It is deliberately NOT used to hold paragraph-length detail text, only
|
||||
* the compact source citation, which is exactly what it's designed for.
|
||||
* - When a source exists, the visible glance (icon or `shortValue` text)
|
||||
* IS the hover/click target for that source, via `SourceLink`, rather
|
||||
* than a separate info-icon next to every value. One affordance per
|
||||
* fact keeps a 58-row table from reading as icon-cluttered.
|
||||
* - A `sr-only` span always carries the complete value, detail, and source
|
||||
* in the initial server-rendered HTML, independent of hover/JS state, so
|
||||
* an LLM or crawler reading the page gets full granularity even though a
|
||||
* human sees only the compact glance.
|
||||
*/
|
||||
export function FactValue({ fact }: FactValueProps) {
|
||||
const { status, text } = parseFactValue(fact.value)
|
||||
const isBoolean = status === 'yes' || status === 'no'
|
||||
const primarySource = fact.sources[0]
|
||||
|
||||
const fullText = [fact.value, fact.detail].filter(Boolean).join('. ')
|
||||
|
||||
const glance = isBoolean ? (
|
||||
status === 'yes' ? (
|
||||
<Check className='size-[14px] shrink-0 text-[var(--text-primary)]' aria-hidden='true' />
|
||||
) : (
|
||||
<X className='size-[14px] shrink-0 text-[var(--text-muted)]' aria-hidden='true' />
|
||||
)
|
||||
) : null
|
||||
|
||||
// A pure yes/no fact renders as an icon only. The "why" lives in the
|
||||
// source link and the sr-only text, not cluttering the glance view.
|
||||
const shortText = isBoolean ? null : (fact.shortValue ?? text)
|
||||
|
||||
const valueNode = glance ?? (
|
||||
<span className='truncate text-[var(--text-body)] text-small'>{shortText}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-1.5'>
|
||||
{primarySource ? (
|
||||
<SourceLink source={primarySource} className={glance ? 'shrink-0' : 'min-w-0 truncate'}>
|
||||
{valueNode}
|
||||
</SourceLink>
|
||||
) : (
|
||||
valueNode
|
||||
)}
|
||||
<span className='sr-only'>{fullText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { FactValueProps } from './fact-value'
|
||||
export { FactValue } from './fact-value'
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { SourceLinkProps } from './source-info'
|
||||
export { SourceLink } from './source-info'
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn, Tooltip } from '@sim/emcn'
|
||||
import type { FactSource } from '@/lib/compare/data'
|
||||
|
||||
export interface SourceLinkProps {
|
||||
source: FactSource
|
||||
children: ReactNode
|
||||
/** Additional classes for the trigger element (the visible value/title). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a fact's visible value (or a card's title) so hovering it directly
|
||||
* shows a one-line "Source: X" tooltip, and clicking it opens the source,
|
||||
* rather than a separate info-icon affordance next to every value. One
|
||||
* hover/click target per fact instead of two keeps the dense comparison
|
||||
* table and card lists from reading as icon-cluttered. Every {@link FactSource}
|
||||
* carries a real, publicly reachable URL (enforced by the type), so this
|
||||
* always renders as a link.
|
||||
*/
|
||||
export function SourceLink({ source, children, className }: SourceLinkProps) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label={`${source.label} (opens source)`}
|
||||
className={cn('block min-w-0', className)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Source: {source.label}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Status implied by a fact's leading "Yes"/"No" token, if any. */
|
||||
export type FactStatus = 'yes' | 'no' | 'neutral'
|
||||
|
||||
export interface ParsedFact {
|
||||
status: FactStatus
|
||||
/** The value with any leading "Yes:"/"No:" token stripped, ready to render next to a status icon. */
|
||||
text: string
|
||||
}
|
||||
|
||||
// The negative lookahead (?![a-zA-Z]) requires the "Yes"/"No" token to end at a word
|
||||
// boundary, so values like "Not documented" or "Not publicly documented" (which start
|
||||
// with the letters "No" but aren't the boolean token) fall through to 'neutral' instead
|
||||
// of being misread as a "No" status. The separator group accepts either a colon
|
||||
// ("Yes: ...") or a comma ("Yes, but ...") since both are used throughout the dataset.
|
||||
const STATUS_PREFIX = /^(Yes|No)(?![a-zA-Z])(?:[:,]\s*)?(.*)$/s
|
||||
|
||||
/**
|
||||
* Splits a {@link Fact.value} string into a status (for a compact icon) and
|
||||
* the remaining descriptive text. Every fact in `apps/sim/lib/compare/data`
|
||||
* that represents a yes/no capability is written as `"Yes: ..."` / `"No: ..."`,
|
||||
* or occasionally `"Yes, ..."` / `"No, ..."` as a more natural continuation.
|
||||
* This is the single place that convention is parsed, so the comparison table
|
||||
* and the key-differences strip render it identically.
|
||||
*/
|
||||
export function parseFactValue(value: string): ParsedFact {
|
||||
const match = value.match(STATUS_PREFIX)
|
||||
if (!match) {
|
||||
return { status: 'neutral', text: value }
|
||||
}
|
||||
const [, token, rest] = match
|
||||
const trimmedRest = rest.trim()
|
||||
return {
|
||||
status: token === 'Yes' ? 'yes' : 'no',
|
||||
text: trimmedRest.length > 0 ? trimmedRest : token,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function ComparisonNotFound() {
|
||||
return (
|
||||
<main
|
||||
id='main-content'
|
||||
className='mx-auto flex min-h-[60vh] w-full max-w-[1446px] flex-col items-center justify-center gap-3 px-12 py-24 text-center max-sm:px-5 max-lg:px-8'
|
||||
>
|
||||
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
Comparison not found
|
||||
</h1>
|
||||
<p className='text-[var(--text-muted)] text-lg'>
|
||||
The comparison you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<ChipLink variant='primary' href='/comparison' className='mt-3'>
|
||||
Browse comparisons
|
||||
</ChipLink>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { simProfile } from '@/lib/compare/data'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { buildLandingMetadata } from '@/lib/landing/seo'
|
||||
import { BrandIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile'
|
||||
import { ALL_COMPETITORS, ensurePeriod, lowercaseFirst } from '@/app/(landing)/comparison/utils'
|
||||
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
|
||||
const baseUrl = SITE_URL
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'How does Sim compare to workflow automation and AI agent platforms?',
|
||||
answer:
|
||||
'Sim is an open-source AI workspace where teams build, deploy, and manage AI agents visually, conversationally, or with code. Compared to workflow automation tools like n8n, Zapier, and Make, Sim treats AI agents as first-class building blocks rather than an add-on to data routing, and ships a native knowledge base, MCP support, and an in-editor AI Copilot. Compared to enterprise AI builders like Gumloop, Workato, StackAI, and Vellum, Sim is fully open source (Apache 2.0) and self-hostable, so teams can run it on their own infrastructure.',
|
||||
},
|
||||
{
|
||||
question: 'Is Sim open source?',
|
||||
answer:
|
||||
'Yes. Sim is released under the Apache License 2.0 and can be self-hosted via Docker or Kubernetes, or used as a managed cloud-hosted service.',
|
||||
},
|
||||
{
|
||||
question: 'Which AI agent platform should I choose?',
|
||||
answer:
|
||||
"The right platform depends on what you're optimizing for: licensing and data control (Sim, n8n self-hosted), integration breadth (Zapier, Pipedream), enterprise governance (Workato, Tines), or AI-native agent building specifically (Sim, Gumloop, StackAI). Each comparison page on this site lays out sourced, dated facts across platform, AI capabilities, integrations, pricing, security, and support so you can weigh the tradeoffs for your team.",
|
||||
},
|
||||
{
|
||||
question: 'Is Sim free to use?',
|
||||
answer: `${ensurePeriod(simProfile.facts.pricing.freeTier.value)} Sim is also free to self-host under the Apache 2.0 license with no seat or usage limits beyond your own infrastructure.`,
|
||||
},
|
||||
{
|
||||
question: 'Does Sim support MCP (Model Context Protocol)?',
|
||||
answer: `${ensurePeriod(simProfile.facts.aiCapabilities.mcpSupport.value)} Sim can also publish any deployed workflow as its own MCP server, so it works as both an MCP client and an MCP server.`,
|
||||
},
|
||||
{
|
||||
question: 'How many integrations does Sim support?',
|
||||
answer: `Sim ships ${ensurePeriod(lowercaseFirst(simProfile.facts.integrations.integrationCount.value))} Combined with native MCP client support, teams can extend Sim to any service with a public API, not just the built-in catalog.`,
|
||||
},
|
||||
]
|
||||
|
||||
export const metadata: Metadata = buildLandingMetadata({
|
||||
title: 'Sim Comparisons | Sim, the AI Workspace',
|
||||
description:
|
||||
'Compare Sim, the open-source AI workspace, to n8n, Zapier, Make, and other workflow automation and AI agent platforms. Sourced, dated, fact-checked.',
|
||||
path: '/comparison',
|
||||
keywords: [
|
||||
'Sim comparison',
|
||||
'Sim vs n8n',
|
||||
'Sim vs Zapier',
|
||||
'Sim alternative',
|
||||
'AI agent platform comparison',
|
||||
'workflow automation comparison',
|
||||
'open source AI workspace',
|
||||
].join(', '),
|
||||
})
|
||||
|
||||
export default function ComparisonHubPage() {
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Comparison', item: `${baseUrl}/comparison` },
|
||||
],
|
||||
}
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Sim Comparisons',
|
||||
description: 'Directory of Sim comparison pages against AI agent and workflow platforms.',
|
||||
url: `${baseUrl}/comparison`,
|
||||
numberOfItems: ALL_COMPETITORS.length,
|
||||
itemListElement: ALL_COMPETITORS.map((competitor, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `${baseUrl}/comparison/${competitor.id}`,
|
||||
name: `Sim vs ${competitor.name}`,
|
||||
})),
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<JsonLd data={itemListJsonLd} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
|
||||
<main id='main-content' className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1446px] px-12 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
{/* Invisible spacer matching the detail page's BackLink block height/margin, so the divider below lands at the same Y on both pages. */}
|
||||
<div className='mb-6 h-6' aria-hidden='true' />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<h1
|
||||
id='comparison-hub-heading'
|
||||
className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Sim comparisons
|
||||
</h1>
|
||||
<p className='max-w-[720px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents.
|
||||
See how Sim compares to workflow automation platforms and AI agent builders on
|
||||
platform architecture, AI capabilities, integrations, pricing, security, and support.
|
||||
</p>
|
||||
<p className='sr-only'>
|
||||
This directory lists every Sim vs. competitor comparison page, covering workflow
|
||||
automation platforms (n8n, Zapier, Make, Pipedream), enterprise AI builders (Gumloop,
|
||||
Workato, Retool, Tines, StackAI, Power Automate, Vellum), and AI agent products
|
||||
(OpenAI AgentKit, Claude Cowork). Each page gives sourced, dated facts across
|
||||
platform, AI capabilities, integrations, pricing, security, and support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1446px] px-12 max-sm:px-5 max-lg:px-8'>
|
||||
<div className='border-[var(--border)] border-x'>
|
||||
<section aria-labelledby='all-comparisons-heading' className='pt-10'>
|
||||
<h2
|
||||
id='all-comparisons-heading'
|
||||
className='mb-4 px-6 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
All comparisons
|
||||
</h2>
|
||||
<div>
|
||||
{ALL_COMPETITORS.map((competitor) => {
|
||||
const Icon = competitor.brand?.icon
|
||||
return (
|
||||
<div key={competitor.id}>
|
||||
<Link
|
||||
href={`/comparison/${competitor.id}`}
|
||||
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover-hover:bg-[var(--surface-hover)]'
|
||||
aria-label={`Sim vs ${competitor.name} comparison`}
|
||||
>
|
||||
{Icon ? (
|
||||
<BrandIconTile
|
||||
icon={Icon}
|
||||
selfFramed={competitor.brand?.selfFramed}
|
||||
className='size-8 shrink-0'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
) : null}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<h3 className='text-[var(--text-primary)] text-sm leading-snug tracking-[-0.02em]'>
|
||||
Sim vs {competitor.name}
|
||||
</h3>
|
||||
<p className='hidden text-[var(--text-muted)] text-caption leading-[150%] sm:line-clamp-1'>
|
||||
{competitor.oneLiner}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronArrow />
|
||||
</Link>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby='faq-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='faq-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div>
|
||||
<LandingFAQ faqs={faqItems} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
type CompetitorProfile,
|
||||
claudeCoworkProfile,
|
||||
crewaiProfile,
|
||||
dustProfile,
|
||||
flowiseProfile,
|
||||
gumloopProfile,
|
||||
langchainProfile,
|
||||
langflowProfile,
|
||||
makeProfile,
|
||||
microsoftCopilotProfile,
|
||||
n8nProfile,
|
||||
openaiAgentkitProfile,
|
||||
openClawProfile,
|
||||
pipedreamProfile,
|
||||
powerAutomateProfile,
|
||||
retoolProfile,
|
||||
simProfile,
|
||||
stackaiProfile,
|
||||
tinesProfile,
|
||||
vellumProfile,
|
||||
workatoProfile,
|
||||
zapierProfile,
|
||||
} from '@/lib/compare/data'
|
||||
|
||||
export interface ComparisonFaq {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
/** Every competitor Sim is compared against, in display/build order. */
|
||||
export const ALL_COMPETITORS: CompetitorProfile[] = [
|
||||
n8nProfile,
|
||||
zapierProfile,
|
||||
makeProfile,
|
||||
gumloopProfile,
|
||||
workatoProfile,
|
||||
retoolProfile,
|
||||
pipedreamProfile,
|
||||
openaiAgentkitProfile,
|
||||
tinesProfile,
|
||||
stackaiProfile,
|
||||
powerAutomateProfile,
|
||||
vellumProfile,
|
||||
claudeCoworkProfile,
|
||||
langflowProfile,
|
||||
flowiseProfile,
|
||||
microsoftCopilotProfile,
|
||||
openClawProfile,
|
||||
dustProfile,
|
||||
crewaiProfile,
|
||||
langchainProfile,
|
||||
]
|
||||
|
||||
const COMPETITOR_BY_SLUG = new Map(ALL_COMPETITORS.map((c) => [c.id, c]))
|
||||
|
||||
export function getCompetitorBySlug(slug: string): CompetitorProfile | null {
|
||||
return COMPETITOR_BY_SLUG.get(slug) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent `asOf` date across every fact source in a profile. Used as
|
||||
* the sitemap `lastModified` for that competitor's comparison page, so the
|
||||
* sitemap reflects when the underlying facts were actually last verified.
|
||||
*/
|
||||
export function getLatestVerifiedDate(profile: CompetitorProfile): Date {
|
||||
let latest = 0
|
||||
for (const group of Object.values(profile.facts)) {
|
||||
for (const fact of Object.values(group as Record<string, { sources: { asOf: string }[] }>)) {
|
||||
for (const source of fact.sources) {
|
||||
const time = new Date(source.asOf).getTime()
|
||||
if (!Number.isNaN(time) && time > latest) {
|
||||
latest = time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest > 0 ? new Date(latest) : new Date()
|
||||
}
|
||||
|
||||
/** Sim's own latest-verified date, identical across every competitor page, computed once. */
|
||||
export const SIM_LATEST_VERIFIED = getLatestVerifiedDate(simProfile)
|
||||
|
||||
/**
|
||||
* A short, atomic "who should pick which platform" verdict. The single
|
||||
* block most comparison-page readers (and AI answer engines asked "should I
|
||||
* use Sim or {competitor}") are actually looking for. Both sentences name
|
||||
* both products explicitly and stay factual, drawing on the competitor's own
|
||||
* documented standout feature rather than a generic claim.
|
||||
*/
|
||||
export interface ComparisonVerdict {
|
||||
chooseSim: string
|
||||
chooseCompetitor: string
|
||||
}
|
||||
|
||||
export function buildBottomLine(competitor: CompetitorProfile): ComparisonVerdict {
|
||||
const strength = competitor.standoutFeatures[0]
|
||||
const chooseCompetitor = strength
|
||||
? `Choose ${competitor.name} if you specifically need ${lowercaseFirst(strength.title)}: ${strength.description}`
|
||||
: `Choose ${competitor.name} if its specific strengths, documented above, matter more to your team than an AI-native, self-hostable workspace.`
|
||||
|
||||
return {
|
||||
chooseSim: `Choose Sim if you want an open-source, self-hostable AI workspace that treats AI agents as first-class citizens: native multi-LLM support, real-time multiplayer editing, environment promotion (dev/qa/prod), human-in-the-loop approvals, and enterprise governance (SSO, credential-level permissions, audit logs) built in rather than bolted on.`,
|
||||
chooseCompetitor,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the FAQ set for a "Sim vs {Competitor}" page. Answer-first, each
|
||||
* question/answer pair is independently quotable per the landing GEO rules,
|
||||
* and every answer names "Sim" and the competitor explicitly. Every answer
|
||||
* draws on a real, sourced {@link Fact} field rather than a generic claim,
|
||||
* and no two questions repeat the same answer.
|
||||
*/
|
||||
export function buildComparisonFaqs(competitor: CompetitorProfile): ComparisonFaq[] {
|
||||
const name = competitor.name
|
||||
const facts = competitor.facts
|
||||
const faqs: ComparisonFaq[] = [
|
||||
{
|
||||
question: `Is Sim a good alternative to ${name}?`,
|
||||
answer: `Sim is an open-source AI workspace where teams build, deploy, and manage AI agents visually, conversationally, or with code. ${ensurePeriod(competitor.oneLiner)} Teams considering a switch typically weigh licensing (Sim is Apache 2.0 and self-hostable), pricing model, and how AI-native the platform's agent-building experience is.`,
|
||||
},
|
||||
{
|
||||
question: `What is the main difference between Sim and ${name}?`,
|
||||
answer: buildKeyDifferenceAnswer(competitor),
|
||||
},
|
||||
{
|
||||
question: `Does Sim support self-hosting compared to ${name}?`,
|
||||
answer: `Sim can be self-hosted via Docker or Kubernetes under an Apache 2.0 license, in addition to a managed cloud-hosted plan. ${name}'s self-hosting position: ${ensurePeriod(firstSentence(facts.platform.selfHostOption.value))}`,
|
||||
},
|
||||
{
|
||||
question: `How does Sim's pricing compare to ${name}?`,
|
||||
answer: `Sim uses ${summarizeFact(simProfile.facts.pricing.pricingModel.value)} ${name} uses ${summarizeFact(facts.pricing.pricingModel.value)}`,
|
||||
},
|
||||
{
|
||||
question: `Is Sim more secure than ${name}?`,
|
||||
answer: `Security is a like-for-like comparison, not a one-line verdict. Sim: ${summarizeFact(simProfile.facts.security.additionalCompliance.value)} ${name}: ${summarizeFact(facts.security.additionalCompliance.value)} Check the Security & compliance rows above for the full breakdown, including SSO, audit logging, and data residency.`,
|
||||
},
|
||||
{
|
||||
question: `Which has stronger AI agent capabilities, Sim or ${name}?`,
|
||||
answer: `Sim: ${summarizeFact(simProfile.facts.aiCapabilities.multiLlmSupport.value)} ${name}: ${summarizeFact(facts.aiCapabilities.multiLlmSupport.value)} Sim also ships native human-in-the-loop approvals, a hybrid vector-plus-keyword knowledge base, and an in-editor AI Copilot that can read execution logs and directly edit the workflow to fix a failed run.`,
|
||||
},
|
||||
{
|
||||
question: `What are ${name}'s documented limitations compared to Sim?`,
|
||||
answer: buildLimitationAnswer(competitor),
|
||||
},
|
||||
{
|
||||
question: `Can I migrate from ${name} to Sim?`,
|
||||
answer: `There is no automated one-click migration tool between ${name} and Sim. Workflows and automations need to be rebuilt in Sim's visual builder, natural-language Chat surface, or API. Most teams start by recreating their highest-value automation first to validate the switch before migrating the rest.`,
|
||||
},
|
||||
]
|
||||
|
||||
if (competitor.isWorkflowBuilder === false) {
|
||||
faqs.push({
|
||||
question: `Is ${name} a workflow builder like Sim?`,
|
||||
answer: `Not in the same sense. ${competitor.oneLiner} Sim, by contrast, is a visual and code-based workflow builder that deploys agents as REST APIs, scheduled jobs, or chat interfaces, so the two solve different parts of the AI agent problem rather than competing feature-for-feature.`,
|
||||
})
|
||||
}
|
||||
|
||||
return faqs
|
||||
}
|
||||
|
||||
function buildKeyDifferenceAnswer(competitor: CompetitorProfile): string {
|
||||
const topFeature = competitor.standoutFeatures[0]
|
||||
const topLimitation = competitor.limitations[0]
|
||||
const parts = [
|
||||
`Sim is built specifically as an AI agent workspace, with native multi-LLM support, an in-editor AI Copilot, and a knowledge base with hybrid vector + keyword search.`,
|
||||
]
|
||||
if (topFeature) {
|
||||
parts.push(`${competitor.name}'s standout capability is ${formatClaim(topFeature)}`)
|
||||
}
|
||||
if (topLimitation) {
|
||||
parts.push(`One documented limitation of ${competitor.name} is ${formatClaim(topLimitation)}`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* A dedicated "what's wrong with the competitor" answer, distinct from
|
||||
* {@link buildKeyDifferenceAnswer} (which leads with Sim's own strengths).
|
||||
* Walks every documented limitation rather than just the first, so the
|
||||
* answer stays substantive even for a two-limitation profile.
|
||||
*/
|
||||
function buildLimitationAnswer(competitor: CompetitorProfile): string {
|
||||
if (competitor.limitations.length === 0) {
|
||||
return `No specific limitations of ${competitor.name} are documented in this comparison yet. See the feature-by-feature table above for a full side-by-side of every category.`
|
||||
}
|
||||
const parts = competitor.limitations.map(formatClaim)
|
||||
return `Documented limitations of ${competitor.name} include ${parts.join('; ')}`
|
||||
}
|
||||
|
||||
/** Renders a titled claim (a standout feature or limitation) as "lowercased title: description". */
|
||||
function formatClaim(item: { title: string; description: string }): string {
|
||||
return `${lowercaseFirst(item.title)}: ${item.description}`
|
||||
}
|
||||
|
||||
function firstSentence(value: string): string {
|
||||
const match = value.match(/^[^.]+\./)
|
||||
return match ? match[0] : value
|
||||
}
|
||||
|
||||
/** Appends a period if `value` doesn't already end in sentence-closing punctuation. */
|
||||
export function ensurePeriod(value: string): string {
|
||||
return /[.!?]$/.test(value) ? value : `${value}.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercases the first letter of `value`, unless its leading word is an acronym
|
||||
* (e.g. "AI", "SSO", "MCP") or a CamelCase brand name (e.g. "LangChain",
|
||||
* "OpenClaw", "CrewAI") - detected by 2+ uppercase letters anywhere in that
|
||||
* word, not just consecutive at the start, since lowercasing either would
|
||||
* mangle a proper noun ("langChain", "openClaw").
|
||||
*/
|
||||
export function lowercaseFirst(value: string): string {
|
||||
if (value.length === 0) return value
|
||||
const leadingWord = value.match(/^[A-Za-z]+/)?.[0] ?? ''
|
||||
const upperCaseCount = (leadingWord.match(/[A-Z]/g) ?? []).length
|
||||
if (upperCaseCount >= 2) return value
|
||||
return value.charAt(0).toLowerCase() + value.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes {@link firstSentence} + {@link lowercaseFirst} + {@link ensurePeriod} for
|
||||
* stitching a fact value mid-sentence. Strips a leading "Yes:"/"No:" (or the
|
||||
* comma-separated "Yes,"/"No,") token first so boolean facts don't produce
|
||||
* mid-sentence "yes: ..."/"no: ..." fragments.
|
||||
*/
|
||||
function summarizeFact(value: string): string {
|
||||
const stripped = value.replace(/^(Yes|No)(?![a-zA-Z])(?:[:,]\s*)?/, '').trim()
|
||||
const base = stripped.length > 0 ? stripped : value
|
||||
return ensurePeriod(lowercaseFirst(firstSentence(base)))
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+361
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { BuildChatAnimation } from './build-chat-animation'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { WorkflowShowcase } from './workflow-showcase'
|
||||
+138
@@ -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
|
||||
+73
@@ -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'
|
||||
+50
@@ -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>
|
||||
)
|
||||
}
|
||||
+35
@@ -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'
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import {
|
||||
HeroChatLoop,
|
||||
type HeroChatPhase,
|
||||
} from '@/app/(landing)/components/hero/components/hero-chat-loop'
|
||||
import { HeroWorkflowStage } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage'
|
||||
import { SidebarHotspots } from '@/app/(landing)/components/hero/components/hero-platform-loop/sidebar-hotspots'
|
||||
import { STAGE_BLOCKS } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
|
||||
|
||||
/**
|
||||
* One pass of the synced loop, matching the REAL platform behavior: the chat
|
||||
* runs FULL-WIDTH (stage collapsed, exactly like `MothershipView`'s `w-0`
|
||||
* state); the user message lands and the Mothership starts thinking; the stage
|
||||
* pane SLIDES IN from the right (the real `w-1/2` + `border-l` width
|
||||
* transition); the workflow assembles block by block inside it; the reply
|
||||
* lands once the flow is built; the scene holds, fades, and restarts.
|
||||
*/
|
||||
const PHASE_STARTS = { user: 500, thinking: 1400 } as const
|
||||
/** The stage pane starts sliding open here (during thinking). */
|
||||
const STAGE_OPEN_AT = 1900
|
||||
/** Block N (build order) pops in at BUILD_START + N * BUILD_STEP. */
|
||||
const BUILD_START = 2400
|
||||
const BUILD_STEP = 620
|
||||
const REPLY_AT = 6400
|
||||
const TOTAL_MS = 12_500
|
||||
const RESET_FADE_MS = 260
|
||||
|
||||
/**
|
||||
* The workspace container's interior in the capture's design space (the shot
|
||||
* is 2560x1470 at 2x, i.e. a 1280x735 CSS layout - oversized vs the 1080x620
|
||||
* window so the whole UI displays at 84.4%, landing the app's native type at
|
||||
* cursor.com's ~12.7px demo scale): x 249-1272, y 7-727. The live layer lays
|
||||
* out at this FIXED size and scales down with the window (`transform: scale`),
|
||||
* so its text and controls shrink in lockstep with the baked sidebar pixels -
|
||||
* the "mini app" reading - instead of rendering at 1:1 CSS sizes and looking
|
||||
* oversized next to the scaled screenshot.
|
||||
*/
|
||||
const CHROME_INTERIOR = { width: 1024, height: 721 } as const
|
||||
|
||||
/**
|
||||
* The hero window's live layer - one flex region replaying the REAL Home
|
||||
* two-pane over the static screenshot. The region covers the workspace
|
||||
* container's interior (inset a hair inside the shot's baked chrome: the
|
||||
* horizontal rules 6px from the card top/bottom (the chrome's `p-[8px]` gap is
|
||||
* tightened to 6px at capture time), the container's left border at ~19.4%,
|
||||
* and its right border at ~99.4%; the container renders at `6px` radius
|
||||
* (overridden at capture time from the chrome's 8px so it DISPLAYS at the
|
||||
* concentric ~4.9px after the 84.4% shot-to-window factor), so the region
|
||||
* clips itself `rounded-[4px]` to hug the baked corner curves without
|
||||
* covering them) so every visible outline is the real UI's pixels.
|
||||
*
|
||||
* Inside, the layout mirrors `Home`: the {@link HeroChatLoop} is a flex-1
|
||||
* `--bg` column; the {@link HeroWorkflowStage} pane animates `w-0 ↔ w-1/2`
|
||||
* with the real `MothershipView` width transition (200ms,
|
||||
* `cubic-bezier(0.25,0.1,0.25,1)`, `border-l` only while open) - the baked
|
||||
* chat|stage divider is covered by this region, so the divider users see is
|
||||
* the live `border-l`, appearing exactly as it does in the product.
|
||||
*
|
||||
* Both panes stay `pointer-events-none` (decorative, matching the hero's
|
||||
* `aria-hidden` frame) - blocks are static. Remounting the stage per cycle
|
||||
* (`key={cycleId}`) resets build state.
|
||||
*
|
||||
* Under `prefers-reduced-motion` the loop never starts: the finished exchange,
|
||||
* open stage, and fully-built workflow render statically.
|
||||
*/
|
||||
export function HeroPlatformLoop() {
|
||||
const regionRef = useRef<HTMLDivElement>(null)
|
||||
const [phase, setPhase] = useState<HeroChatPhase>('idle')
|
||||
const [stageOpen, setStageOpen] = useState(false)
|
||||
const [builtCount, setBuiltCount] = useState(0)
|
||||
const [fading, setFading] = useState(false)
|
||||
const [cycleId, setCycleId] = useState(0)
|
||||
const [scale, setScale] = useState(1)
|
||||
|
||||
// Track the rendered region width and scale the design-space layer to fill
|
||||
// it, keeping the live layer's proportions locked to the screenshot's.
|
||||
useLayoutEffect(() => {
|
||||
const el = regionRef.current
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect().width
|
||||
if (w > 40) setScale(w / CHROME_INTERIOR.width)
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
let timers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
const clearScheduled = () => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers = []
|
||||
}
|
||||
|
||||
const showFinished = () => {
|
||||
clearScheduled()
|
||||
setFading(false)
|
||||
setPhase('reply')
|
||||
setStageOpen(true)
|
||||
setBuiltCount(STAGE_BLOCKS.length)
|
||||
}
|
||||
|
||||
const runCycle = () => {
|
||||
setFading(false)
|
||||
setPhase('idle')
|
||||
setStageOpen(false)
|
||||
setBuiltCount(0)
|
||||
setCycleId((c) => c + 1)
|
||||
timers = [
|
||||
setTimeout(() => setPhase('user'), PHASE_STARTS.user),
|
||||
setTimeout(() => setPhase('thinking'), PHASE_STARTS.thinking),
|
||||
setTimeout(() => setStageOpen(true), STAGE_OPEN_AT),
|
||||
...STAGE_BLOCKS.map((_, i) =>
|
||||
setTimeout(() => setBuiltCount(i + 1), BUILD_START + i * BUILD_STEP)
|
||||
),
|
||||
setTimeout(() => setPhase('reply'), REPLY_AT),
|
||||
setTimeout(() => setFading(true), TOTAL_MS - RESET_FADE_MS),
|
||||
setTimeout(runCycle, TOTAL_MS),
|
||||
]
|
||||
}
|
||||
|
||||
const syncMotionPreference = () => {
|
||||
clearScheduled()
|
||||
if (media.matches) {
|
||||
showFinished()
|
||||
return
|
||||
}
|
||||
runCycle()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
clearScheduled()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarHotspots />
|
||||
<div
|
||||
ref={regionRef}
|
||||
className='absolute top-[0.95%] right-[0.55%] bottom-[0.95%] left-[19.45%] overflow-hidden rounded-[4px]'
|
||||
>
|
||||
<div
|
||||
className='flex origin-top-left'
|
||||
style={{
|
||||
width: CHROME_INTERIOR.width,
|
||||
height: CHROME_INTERIOR.height,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<div className='pointer-events-none relative h-full min-w-0 flex-1'>
|
||||
<HeroChatLoop phase={phase} fading={fading} />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full shrink-0 overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
|
||||
stageOpen ? 'w-1/2 border-l' : 'w-0 min-w-0 border-l-0'
|
||||
)}
|
||||
>
|
||||
<HeroWorkflowStage key={cycleId} builtCount={builtCount} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { StageBlockCard } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-block-card'
|
||||
import {
|
||||
handleAnchors,
|
||||
STAGE_BLOCKS,
|
||||
STAGE_CANVAS,
|
||||
STAGE_EDGES,
|
||||
verticalSmoothStep,
|
||||
} from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
|
||||
import {
|
||||
BLOCK_WIDTH,
|
||||
type BlockDef,
|
||||
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/** Upper bound on the canvas render scale (the scale at the full 1300px cap). */
|
||||
const MAX_STAGE_SCALE = 0.71
|
||||
/** Breathing room between the canvas bounds and the card edges, in card px. */
|
||||
const STAGE_MARGIN = 20
|
||||
|
||||
interface HeroWorkflowStageProps {
|
||||
/** How many of the stage's blocks (in build order) are on canvas. */
|
||||
builtCount: number
|
||||
/** Blocks to stage, in build order. Defaults to the homepage's lead flow. */
|
||||
blocks?: BlockDef[]
|
||||
/** Source → target pairs among {@link blocks}. Defaults with them. */
|
||||
edges?: ReadonlyArray<readonly [string, string]>
|
||||
/** Design-space bounding box of the block layout. Defaults with them. */
|
||||
canvas?: { width: number; height: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero window's live workflow canvas - the right-pane counterpart of the
|
||||
* chat loop. Blocks pop in one by one as `builtCount` advances (staggered
|
||||
* scale/fade entrances, edges stroke-draw once both endpoints exist) at their
|
||||
* fixed positions. The edge SVG is `overflow-visible` - SVGs clip
|
||||
* at their viewport by default, which would cut the lines if a block ever sat
|
||||
* outside the design-canvas bounds.
|
||||
*
|
||||
* Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
|
||||
* draggable - `pointer-events-none`, matching the rest of the hero animation.
|
||||
*
|
||||
* Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful
|
||||
* icon-tile + rows card body) in a card shell with vertical-flow handle nubs
|
||||
* (top in / bottom out), matching the real editor's vertical layout.
|
||||
*
|
||||
* The staged flow is injectable (`blocks`/`edges`/`canvas`), defaulting to the
|
||||
* homepage's lead-enrichment flow - the enterprise loop stages its own flow
|
||||
* through the same component.
|
||||
*/
|
||||
export function HeroWorkflowStage({
|
||||
builtCount,
|
||||
blocks = STAGE_BLOCKS,
|
||||
edges = STAGE_EDGES,
|
||||
canvas = STAGE_CANVAS,
|
||||
}: HeroWorkflowStageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [scale, setScale] = useState(MAX_STAGE_SCALE)
|
||||
const blocksById = useMemo(() => new Map(blocks.map((b) => [b.id, b])), [blocks])
|
||||
|
||||
// Fit the design canvas to the card: scale down when the pane narrows so the
|
||||
// branch blocks never clip, capped at the full-width scale. Measures LAYOUT
|
||||
// size (offsetWidth/Height) - the stage lives inside the platform loop's
|
||||
// scale-transformed design-space layer, and getBoundingClientRect's visual
|
||||
// size would compound that outer scale into a double shrink.
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const w = el.offsetWidth
|
||||
const h = el.offsetHeight
|
||||
if (w < 40 || h < 40) return
|
||||
setScale(
|
||||
Math.min(
|
||||
MAX_STAGE_SCALE,
|
||||
(w - STAGE_MARGIN) / canvas.width,
|
||||
(h - STAGE_MARGIN) / canvas.height
|
||||
)
|
||||
)
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [canvas.width, canvas.height])
|
||||
|
||||
const builtIds = useMemo(
|
||||
() => new Set(blocks.slice(0, builtCount).map((b) => b.id)),
|
||||
[blocks, builtCount]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex h-full w-full items-center justify-center overflow-hidden'
|
||||
>
|
||||
<div
|
||||
className='relative shrink-0'
|
||||
style={{
|
||||
width: canvas.width * scale,
|
||||
height: canvas.height * scale,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='absolute top-0 left-0'
|
||||
style={{
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className='pointer-events-none absolute inset-0 overflow-visible'
|
||||
width={canvas.width}
|
||||
height={canvas.height}
|
||||
viewBox={`0 0 ${canvas.width} ${canvas.height}`}
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{edges.map(([from, to]) => {
|
||||
const source = blocksById.get(from)
|
||||
const target = blocksById.get(to)
|
||||
if (!source || !target) return null
|
||||
const visible = builtIds.has(from) && builtIds.has(to)
|
||||
const s = handleAnchors(source).out
|
||||
const t = handleAnchors(target).in
|
||||
return (
|
||||
<path
|
||||
key={`${from}-${to}`}
|
||||
d={verticalSmoothStep(s.x, s.y, t.x, t.y)}
|
||||
pathLength={1}
|
||||
stroke='var(--workflow-edge)'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
className='transition-[stroke-dashoffset] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] [stroke-dasharray:1]'
|
||||
style={{ strokeDashoffset: visible ? 0 : 1 } as CSSProperties}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{blocks.map((block) => {
|
||||
const built = builtIds.has(block.id)
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className={cn(
|
||||
'pointer-events-none absolute transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
|
||||
)}
|
||||
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
|
||||
>
|
||||
<StageBlockCard block={block} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HeroPlatformLoop } from './hero-platform-loop'
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@sim/emcn'
|
||||
|
||||
/**
|
||||
* Cursor-to-bubble gap for tooltips over the mini platform UI - tighter than
|
||||
* the product-standard 16px so the bubble hugs the cursor proportionately to
|
||||
* the scaled-down preview.
|
||||
*/
|
||||
export const HERO_TOOLTIP_OFFSET = 8
|
||||
|
||||
/** Percent-of-image bounds shared by both hotspot kinds. */
|
||||
interface HotspotBounds {
|
||||
left: string
|
||||
top: string
|
||||
width: string
|
||||
height: string
|
||||
}
|
||||
|
||||
/** An icon control that shows a tooltip on hover, like the real product. */
|
||||
interface TooltipHotspot extends HotspotBounds {
|
||||
label: string
|
||||
}
|
||||
|
||||
/** A sidebar nav row that shows the real row-hover highlight on hover. */
|
||||
interface RowHotspot extends HotspotBounds {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon controls measured from the capture (2560x1470): the collapse-sidebar
|
||||
* toggle (x428-468, y36-72) and the Workflows section's "More actions"
|
||||
* ellipsis (x376-412) and "Create workflow" plus (x424-460) at y920-952.
|
||||
* Copy matches the real product's tooltips.
|
||||
*/
|
||||
const TOOLTIP_HOTSPOTS: TooltipHotspot[] = [
|
||||
{ label: 'Collapse sidebar', left: '16.72%', top: '2.45%', width: '1.56%', height: '2.45%' },
|
||||
{ label: 'More actions', left: '14.69%', top: '62.59%', width: '1.4%', height: '2.18%' },
|
||||
{ label: 'Create workflow', left: '16.56%', top: '62.59%', width: '1.4%', height: '2.18%' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Workspace nav rows (Tables / Files / Knowledge base), boxed like the real
|
||||
* sidebar row (`h-[30px] rounded-lg`, text rows measured at y598-616, 662-680,
|
||||
* 726-750): a 60px-tall (at 2x) highlight box centered on each row's text,
|
||||
* starting left of the row ICON (icons measured at x37-39, the box carries the
|
||||
* real row's 8px icon gutter) so the icon sits inside the highlight.
|
||||
*/
|
||||
const ROW_HOTSPOTS: RowHotspot[] = [
|
||||
{ name: 'Tables', left: '0.82%', top: '39.25%', width: '17.15%', height: '4.08%' },
|
||||
{ name: 'Files', left: '0.82%', top: '43.61%', width: '17.15%', height: '4.08%' },
|
||||
{ name: 'Knowledge base', left: '0.82%', top: '48.16%', width: '17.15%', height: '4.08%' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Hover layers over the BAKED sidebar pixels. Icon controls (collapse toggle,
|
||||
* Workflows more/create) get transparent targets wired to the emcn
|
||||
* {@link Tooltip} with the product's real copy ({@link HERO_TOOLTIP_OFFSET}
|
||||
* keeps the bubble close over the mini UI). The Workspace nav rows instead
|
||||
* reproduce the REAL sidebar row-hover state - `--surface-active` on the
|
||||
* row's rounded box - via `mix-blend-multiply`, which paints over the baked
|
||||
* white/text pixels to exactly the result of a background behind the text.
|
||||
*/
|
||||
export function SidebarHotspots() {
|
||||
return (
|
||||
<div aria-hidden='true' className='pointer-events-none absolute inset-0'>
|
||||
{TOOLTIP_HOTSPOTS.map((spot) => (
|
||||
<Tooltip.Root key={spot.label}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
aria-label={spot.label}
|
||||
className='pointer-events-auto absolute block'
|
||||
style={{ left: spot.left, top: spot.top, width: spot.width, height: spot.height }}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content offset={HERO_TOOLTIP_OFFSET}>{spot.label}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
))}
|
||||
{ROW_HOTSPOTS.map((row) => (
|
||||
<span
|
||||
key={row.name}
|
||||
aria-label={row.name}
|
||||
className='pointer-events-auto absolute block cursor-pointer rounded-[6px] mix-blend-multiply transition-colors duration-100 hover:bg-[var(--surface-active)]'
|
||||
style={{ left: row.left, top: row.top, width: row.width, height: row.height }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { WorkflowBlockContent } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block-content'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
interface StageBlockCardProps {
|
||||
block: BlockDef
|
||||
}
|
||||
|
||||
/**
|
||||
* Block card shell for the vertical-flow stage - the hero-visual's faithful
|
||||
* {@link WorkflowBlockContent} body with top (incoming) / bottom (outgoing)
|
||||
* handle nubs instead of the horizontal-flow left/right ones. Shared by the
|
||||
* hero's live workflow stage and the Build feature card's workflow peek.
|
||||
*/
|
||||
export function StageBlockCard({ block }: StageBlockCardProps) {
|
||||
return (
|
||||
<div className='relative rounded-[13px] border border-[var(--border-1)] bg-[var(--surface-2)] shadow-sm'>
|
||||
<WorkflowBlockContent block={block} />
|
||||
{!block.isTrigger && (
|
||||
<span
|
||||
aria-hidden
|
||||
className='-translate-x-1/2 absolute top-[-7px] left-1/2 h-[7px] w-5 rounded-t-[2px] bg-[var(--workflow-edge)]'
|
||||
/>
|
||||
)}
|
||||
{!block.isTerminal && (
|
||||
<span
|
||||
aria-hidden
|
||||
className='-translate-x-1/2 absolute bottom-[-7px] left-1/2 h-[7px] w-5 rounded-b-[2px] bg-[var(--workflow-edge)]'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { AgentIcon, CodeIcon, SlackIcon, StartIcon, TableIcon } from '@/components/icons'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
import { BLOCK_WIDTH } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/**
|
||||
* Design-space geometry for the hero's live workflow stage - the lead-enrichment
|
||||
* flow the chat conversation "builds": Start feeds the enrichment agent, a
|
||||
* scoring function follows, and the flow fans out to Slack and Tables. Block
|
||||
* tiles use the platform's grey text ramp (each block a different shade, dark
|
||||
* enough to carry the white glyph) - color is reserved for REAL third-party
|
||||
* marks, so only Slack keeps its brand tile (#611F69).
|
||||
*
|
||||
* Blocks are ordered by build sequence - the stage reveals `blocks[0..built-1]`
|
||||
* as the loop's build counter advances, and an edge draws once both its
|
||||
* endpoints are on canvas.
|
||||
*/
|
||||
export const STAGE_BLOCKS: BlockDef[] = [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
icon: StartIcon,
|
||||
bgColor: 'var(--text-muted)',
|
||||
isTrigger: true,
|
||||
rows: [{ title: 'Inputs', value: '-' }],
|
||||
x: 155,
|
||||
y: 12,
|
||||
},
|
||||
{
|
||||
id: 'enrich',
|
||||
name: 'Enrich lead',
|
||||
icon: AgentIcon,
|
||||
bgColor: 'var(--text-primary)',
|
||||
rows: [
|
||||
{ title: 'Messages', value: '-' },
|
||||
{ title: 'Model', value: '-' },
|
||||
{ title: 'Files', value: '-' },
|
||||
],
|
||||
x: 155,
|
||||
y: 172,
|
||||
},
|
||||
{
|
||||
id: 'score',
|
||||
name: 'Score fit',
|
||||
icon: CodeIcon,
|
||||
bgColor: 'var(--text-secondary)',
|
||||
rows: [
|
||||
{ title: 'Code', value: '-' },
|
||||
{ title: 'Timeout', value: '-' },
|
||||
],
|
||||
x: 155,
|
||||
y: 390,
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Post to #sales',
|
||||
icon: SlackIcon,
|
||||
bgColor: '#611F69',
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'Channel', value: '-' },
|
||||
{ title: 'Message', value: '-' },
|
||||
],
|
||||
x: 0,
|
||||
y: 580,
|
||||
},
|
||||
{
|
||||
id: 'tables',
|
||||
name: 'Save to Tables',
|
||||
icon: TableIcon,
|
||||
bgColor: 'var(--text-body)',
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'Table', value: '-' },
|
||||
{ title: 'Operation', value: '-' },
|
||||
],
|
||||
x: 310,
|
||||
y: 580,
|
||||
},
|
||||
]
|
||||
|
||||
/** Source → target pairs, drawn in order as their endpoints land on canvas. */
|
||||
export const STAGE_EDGES: ReadonlyArray<readonly [string, string]> = [
|
||||
['start', 'enrich'],
|
||||
['enrich', 'score'],
|
||||
['score', 'slack'],
|
||||
['score', 'tables'],
|
||||
]
|
||||
|
||||
/** Design-space bounding box of the layout above. */
|
||||
export const STAGE_CANVAS = { width: 560, height: 700 } as const
|
||||
|
||||
/**
|
||||
* Approximate rendered block height - the icon-tile header (~40px) plus the
|
||||
* rows section (16px padding + 21px per row + 8px gaps). Used to place a
|
||||
* block's bottom (outgoing) handle; a few px of drift is invisible at stage
|
||||
* scale.
|
||||
*/
|
||||
export function blockHeight(block: BlockDef): number {
|
||||
const n = block.rows.length
|
||||
return 40 + (n > 0 ? 16 + n * 21 + (n - 1) * 8 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded orthogonal ("smoothstep") path for a VERTICAL flow - from a source's
|
||||
* bottom-center handle to a target's top-center handle, stepping at the
|
||||
* vertical midpoint with `r`-radius corners. The horizontal-flow counterpart
|
||||
* lives in `hero-visual/workflow-data.ts`.
|
||||
*/
|
||||
export function verticalSmoothStep(sx: number, sy: number, tx: number, ty: number, r = 8): string {
|
||||
if (Math.abs(tx - sx) < 1) return `M ${sx} ${sy} L ${tx} ${ty}`
|
||||
const midY = (sy + ty) / 2
|
||||
const dir = tx >= sx ? 1 : -1
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`L ${sx} ${midY - r}`,
|
||||
`Q ${sx} ${midY} ${sx + dir * r} ${midY}`,
|
||||
`L ${tx - dir * r} ${midY}`,
|
||||
`Q ${tx} ${midY} ${tx} ${midY + r}`,
|
||||
`L ${tx} ${ty}`,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
/** Handle anchor points for a block at its fixed position. */
|
||||
export function handleAnchors(block: BlockDef) {
|
||||
return {
|
||||
out: { x: block.x + BLOCK_WIDTH / 2, y: block.y + blockHeight(block) },
|
||||
in: { x: block.x + BLOCK_WIDTH / 2, y: block.y },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Plain fade-in for the hero stat number - one smooth opacity ramp. */
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fadeIn {
|
||||
animation: fade-in 900ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* Per-character entrance for the placeholder zeros: the leading "0" fades in
|
||||
* first, then each following character staggers in left to right (70ms per
|
||||
* character; the 11th lands at 700ms + 500ms fade = fully in by ~1.2s).
|
||||
*/
|
||||
.char {
|
||||
animation: fade-in 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.char:nth-child(2) {
|
||||
animation-delay: 70ms;
|
||||
}
|
||||
|
||||
.char:nth-child(3) {
|
||||
animation-delay: 140ms;
|
||||
}
|
||||
|
||||
.char:nth-child(4) {
|
||||
animation-delay: 210ms;
|
||||
}
|
||||
|
||||
.char:nth-child(5) {
|
||||
animation-delay: 280ms;
|
||||
}
|
||||
|
||||
.char:nth-child(6) {
|
||||
animation-delay: 350ms;
|
||||
}
|
||||
|
||||
.char:nth-child(7) {
|
||||
animation-delay: 420ms;
|
||||
}
|
||||
|
||||
.char:nth-child(8) {
|
||||
animation-delay: 490ms;
|
||||
}
|
||||
|
||||
.char:nth-child(9) {
|
||||
animation-delay: 560ms;
|
||||
}
|
||||
|
||||
.char:nth-child(10) {
|
||||
animation-delay: 630ms;
|
||||
}
|
||||
|
||||
.char:nth-child(11) {
|
||||
animation-delay: 700ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fadeIn,
|
||||
.char {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import styles from '@/app/(landing)/components/hero/components/hero-stat/hero-stat.module.css'
|
||||
|
||||
const STAT_VALUE = '0.00000206%'
|
||||
/** The last-three-digit count-up range: 000 -> 206 (Stripe-style iterate). */
|
||||
const COUNT_FROM = 0
|
||||
const COUNT_TO = 206
|
||||
/** Duration of the count-up once it starts. */
|
||||
const COUNT_DUR_MS = 1100
|
||||
/** The LIGHT-GREY placeholder zeros the number staggers in as, char by char. */
|
||||
const START_CHARS = `0.00000${String(COUNT_FROM).padStart(3, '0')}%`.split('')
|
||||
/**
|
||||
* When the per-character stagger has fully landed (last char starts at 700ms
|
||||
* + its 500ms fade - see hero-stat.module.css).
|
||||
*/
|
||||
const CHARS_IN_AT_MS = 1200
|
||||
/**
|
||||
* The settle beat starts once the character stagger completes, plus a short
|
||||
* hold so the number reads before it rises.
|
||||
*/
|
||||
const SETTLE_AT_MS = CHARS_IN_AT_MS + 150
|
||||
/**
|
||||
* The label reveal waits until the rising number (500ms ease-out) has cleared
|
||||
* the label's row, so the two never overlap mid-flight.
|
||||
*/
|
||||
const REVEAL_AT_MS = SETTLE_AT_MS + 400
|
||||
/** The dark fill grows in last, once the label's fade-up has finished. */
|
||||
const FILL_AT_MS = REVEAL_AT_MS + 350
|
||||
|
||||
/**
|
||||
* The hero's right-side stat, with a staggered page-load entrance in four
|
||||
* beats: the number appears at the bottom of the stat as LIGHT-GREY
|
||||
* placeholder zeros - the leading "0" fades in first, then each following
|
||||
* character staggers in left to right - alongside the progress rail (short,
|
||||
* bottom-anchored, spanning exactly the number's 18px line), the number rises
|
||||
* into its final slot while the rail GROWS upward at the same rate (same
|
||||
* duration + easing, so its top edge tracks the number's top), then the
|
||||
* "Global work done by Sim" label fades up beneath it while the number
|
||||
* DARKENS to `--text-primary` and the last digits COUNT UP 000 -> 206
|
||||
* (Stripe-style, ease-out so the ticks slow into the final value), and
|
||||
* finally the dark progress fill grows up from the rail's foot.
|
||||
*
|
||||
* Under `prefers-reduced-motion` everything renders settled immediately (the
|
||||
* fades are also disabled in the CSS module).
|
||||
*/
|
||||
export function HeroStat() {
|
||||
const [settled, setSettled] = useState(false)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [filled, setFilled] = useState(false)
|
||||
const [count, setCount] = useState(COUNT_FROM)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
let raf = 0
|
||||
let settleTimer: ReturnType<typeof setTimeout>
|
||||
let revealTimer: ReturnType<typeof setTimeout>
|
||||
let fillTimer: ReturnType<typeof setTimeout>
|
||||
let countTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
const clearScheduled = () => {
|
||||
clearTimeout(settleTimer)
|
||||
clearTimeout(revealTimer)
|
||||
clearTimeout(fillTimer)
|
||||
clearTimeout(countTimer)
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
|
||||
const showSettled = () => {
|
||||
clearScheduled()
|
||||
setSettled(true)
|
||||
setRevealed(true)
|
||||
setFilled(true)
|
||||
setCount(COUNT_TO)
|
||||
}
|
||||
|
||||
const runEntrance = () => {
|
||||
setSettled(false)
|
||||
setRevealed(false)
|
||||
setFilled(false)
|
||||
setCount(COUNT_FROM)
|
||||
settleTimer = setTimeout(() => setSettled(true), SETTLE_AT_MS)
|
||||
revealTimer = setTimeout(() => setRevealed(true), REVEAL_AT_MS)
|
||||
fillTimer = setTimeout(() => setFilled(true), FILL_AT_MS)
|
||||
countTimer = setTimeout(() => {
|
||||
const start = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min((now - start) / COUNT_DUR_MS, 1)
|
||||
const eased = 1 - (1 - t) ** 3
|
||||
setCount(Math.round(COUNT_FROM + (COUNT_TO - COUNT_FROM) * eased))
|
||||
if (t < 1) raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}, REVEAL_AT_MS)
|
||||
}
|
||||
|
||||
// Re-synced on 'change' - not just on mount - so toggling the preference
|
||||
// mid-entrance snaps straight to the settled state instead of leaving the
|
||||
// stagger/count-up to keep running on its own clock.
|
||||
const syncMotionPreference = () => {
|
||||
clearScheduled()
|
||||
if (media.matches) {
|
||||
showSettled()
|
||||
return
|
||||
}
|
||||
runEntrance()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
clearScheduled()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex w-fit shrink-0 items-stretch gap-3 max-lg:hidden'>
|
||||
<div aria-hidden='true' className={cn('relative w-[2px]', styles.fadeIn)}>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 bottom-0 flex flex-col justify-end rounded-full bg-[var(--surface-6)] transition-[top] duration-500',
|
||||
settled ? 'top-0' : 'top-[calc(100%-18px)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-full bg-[var(--text-body)] transition-[height] duration-300',
|
||||
filled ? 'h-[4px]' : 'h-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[18px] tabular-nums leading-none transition-[transform,color] duration-500',
|
||||
settled ? 'translate-y-0' : 'translate-y-[20px]',
|
||||
revealed ? 'text-[var(--text-primary)]' : 'text-[var(--surface-7)]'
|
||||
)}
|
||||
>
|
||||
<span className='sr-only'>{STAT_VALUE}</span>
|
||||
<span aria-hidden='true'>
|
||||
{revealed
|
||||
? `0.00000${String(count).padStart(3, '0')}%`
|
||||
: START_CHARS.map((ch, i) => (
|
||||
<span key={i} className={styles.char}>
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[var(--text-muted)] text-sm leading-none transition-[opacity,transform] duration-300',
|
||||
revealed ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'
|
||||
)}
|
||||
>
|
||||
Global work done by Sim
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HeroStat } from './hero-stat'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
interface BlockHandlesProps {
|
||||
block: BlockDef
|
||||
handlesVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The decorative edge-handle nubs for a block - an inbound nub on the left
|
||||
* unless the block is a trigger, an outbound nub on the right unless it's
|
||||
* terminal. Absolutely positioned, so the caller must be a `relative` (or
|
||||
* otherwise positioned) box of the block's width. Shared so the morphed chat
|
||||
* card (GitHub, rendered as content-only) can carry the same handles as the
|
||||
* real {@link WorkflowBlock} satellites.
|
||||
*/
|
||||
export function BlockHandles({ block, handlesVisible = true }: BlockHandlesProps) {
|
||||
return (
|
||||
<>
|
||||
{!block.isTrigger && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'-translate-y-1/2 absolute top-5 left-[-7px] h-5 w-[7px] rounded-l-[2px] bg-[var(--workflow-edge)] transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
handlesVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!block.isTerminal && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'-translate-y-1/2 absolute top-5 right-[-7px] h-5 w-[7px] rounded-r-[2px] bg-[var(--workflow-edge)] transition-opacity duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
handlesVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user