diff --git a/apps/sim/app/(auth)/components/auth-shell.test.tsx b/apps/sim/app/(auth)/components/auth-shell.test.tsx
new file mode 100644
index 00000000000..2248773b9f6
--- /dev/null
+++ b/apps/sim/app/(auth)/components/auth-shell.test.tsx
@@ -0,0 +1,24 @@
+/** @vitest-environment node */
+import type { ReactNode } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { expect, it, vi } from 'vitest'
+import { AuthShell } from '@/app/(auth)/components/auth-shell'
+
+vi.mock('next/link', () => ({
+ default: ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+vi.mock('@/app/_shell/desktop-title-bar', () => ({ DesktopTitleBarLane: () => null }))
+vi.mock('@/app/(landing)/components/navbar/components', () => ({
+ LogoMark: ({ children }: { children: ReactNode }) => <>{children}>,
+ SimWordmark: () => 'Sim',
+}))
+
+it('returns home through a document link so route-specific theme defaults reinitialize', () => {
+ const html = renderToStaticMarkup(Sign in)
+ expect(html).toContain('href="/" aria-label="Sim home"')
+ expect(html).not.toContain('data-client-navigation')
+})
diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx
index 36085dc52ad..d7107dfeb96 100644
--- a/apps/sim/app/(auth)/components/auth-shell.tsx
+++ b/apps/sim/app/(auth)/components/auth-shell.tsx
@@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
-import Link from 'next/link'
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components'
@@ -19,6 +18,8 @@ interface AuthShellProps {
* the canvas/`--text-primary` surface, and renders a logo-only header that reuses
* the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The
* single content column is centered and capped for a calm single-form layout.
+ * The home link starts a document navigation so the marketing theme default is
+ * initialized independently of auth's forced-light context.
*
* The shell also owns the macOS traffic-light lane, unconditionally — every surface that
* wears it (the `(auth)` routes, the CLI auth handoff, the invite pages) sits outside
@@ -34,11 +35,11 @@ export function AuthShell({ children, footer }: AuthShellProps) {
diff --git a/apps/sim/app/(auth)/signup/signup-form.test.tsx b/apps/sim/app/(auth)/signup/signup-form.test.tsx
new file mode 100644
index 00000000000..fac82dd3667
--- /dev/null
+++ b/apps/sim/app/(auth)/signup/signup-form.test.tsx
@@ -0,0 +1,132 @@
+/** @vitest-environment jsdom */
+import { act, type InputHTMLAttributes, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import SignupForm from '@/app/(auth)/signup/signup-form'
+
+const { push, signUp, refetchSession } = vi.hoisted(() => ({
+ push: vi.fn(),
+ signUp: vi.fn(),
+ refetchSession: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push }),
+ useSearchParams: () => new URLSearchParams(),
+}))
+vi.mock('@marsidev/react-turnstile', () => ({ Turnstile: () => null }))
+vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
+vi.mock('@/lib/analytics/google', () => ({ trackGoogleEvent: vi.fn() }))
+vi.mock('@/lib/auth/auth-client', () => ({
+ client: { signUp: { email: signUp } },
+ useSession: () => ({ refetch: refetchSession }),
+}))
+vi.mock('@/lib/consent/tracking-consent', () => ({
+ useTrackingConsent: () => ({ measurement: false }),
+}))
+vi.mock('@/lib/core/config/env', () => ({ getEnv: () => undefined, isFalsy: () => false }))
+vi.mock('@/lib/core/config/env-flags', () => ({ isSsoEnabled: false }))
+vi.mock('@/lib/core/security/input-validation', () => ({ validateCallbackUrl: () => false }))
+vi.mock('@/lib/messaging/email/validation', () => ({
+ quickValidateEmail: () => ({ isValid: true }),
+}))
+vi.mock('@/lib/posthog/client', () => ({ captureClientEvent: vi.fn(), captureEvent: vi.fn() }))
+vi.mock('@/app/(auth)/components', () => ({
+ AuthDivider: () => null,
+ AuthField: ({ children }: { children: ReactNode }) => <>{children}>,
+ AuthFormMessage: () => null,
+ AuthHeader: () => null,
+ AuthInput: ({ error, ...props }: InputHTMLAttributes & { error?: boolean }) => (
+
+ ),
+ AuthLegalFooter: () => null,
+ AuthNavPrompt: () => null,
+ AuthSubmitButton: ({ children }: { children: ReactNode }) => (
+
+ ),
+ PasswordInput: ({
+ error,
+ ...props
+ }: InputHTMLAttributes & { error?: boolean }) => ,
+ SocialLoginButtons: () => null,
+ SSOLoginButton: () => null,
+}))
+
+let root: Root
+let host: HTMLDivElement
+let destination: { href: string }
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ destination = { href: '' }
+ const browser = window
+ vi.stubGlobal(
+ 'window',
+ new Proxy(browser, {
+ get(target, key) {
+ return key === 'location' ? destination : Reflect.get(target, key, target)
+ },
+ })
+ )
+ signUp.mockResolvedValue({ data: { user: { id: 'new-user' } } })
+ refetchSession.mockResolvedValue(undefined)
+ host = document.createElement('div')
+ document.body.append(host)
+ root = createRoot(host)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ host.remove()
+ vi.unstubAllGlobals()
+})
+
+async function submit(emailVerificationEnabled: boolean) {
+ act(() =>
+ root.render(
+
+ )
+ )
+ const fields = { name: 'Test Builder', email: 'builder@example.com', password: 'SafePass1!' }
+ for (const [name, value] of Object.entries(fields)) {
+ const input = host.querySelector(`input[name="${name}"]`)
+ if (!input) throw new Error(`Missing ${name} input`)
+ input.value = value
+ }
+ const form = host.querySelector('form')
+ if (!form) throw new Error('Missing signup form')
+ await act(async () =>
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ )
+}
+
+describe('signup shell navigation', () => {
+ it('starts a document navigation after a successful signup without verification', async () => {
+ await submit(false)
+ expect(signUp).toHaveBeenCalledOnce()
+ expect(refetchSession).toHaveBeenCalledOnce()
+ expect(destination.href).toBe('/workspace')
+ expect(push).not.toHaveBeenCalled()
+ })
+
+ it('keeps verification within the auth shell and stores the email for the next step', async () => {
+ await submit(true)
+ expect(push).toHaveBeenCalledWith('/verify?fromSignup=true')
+ expect(sessionStorage.getItem('verificationEmail')).toBe('builder@example.com')
+ expect(destination.href).toBe('')
+ })
+
+ it('does not navigate when signup fails', async () => {
+ signUp.mockResolvedValue({ error: { message: 'Signup failed' } })
+ await submit(false)
+ expect(push).not.toHaveBeenCalled()
+ expect(destination.href).toBe('')
+ })
+})
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index 61ad48a8328..e945c6ec16f 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -372,12 +372,10 @@ function SignupFormContent({
if (destination.kind === 'verify') {
router.push(VERIFY_FROM_SIGNUP_ROUTE)
- } else if (destination.kind === 'redirect') {
- // Full navigation, matching the verify hop: the destination (invite, CLI
- // handoff) is server-rendered and must see the fresh session cookie.
- window.location.href = destination.url
} else {
- router.push(DEFAULT_POST_AUTH_ROUTE)
+ /** Match login/verification: refresh session-bound shells and their theme default. */
+ window.location.href =
+ destination.kind === 'redirect' ? destination.url : DEFAULT_POST_AUTH_ROUTE
}
} catch (error) {
logger.error('Signup error:', error)
diff --git a/apps/sim/app/(landing)/CLAUDE.md b/apps/sim/app/(landing)/CLAUDE.md
index 16c31795602..0f087003f79 100644
--- a/apps/sim/app/(landing)/CLAUDE.md
+++ b/apps/sim/app/(landing)/CLAUDE.md
@@ -6,14 +6,14 @@ This route group owns `/` and the entire public marketing surface - the home pag
## 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 `` content.
+- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, painted pre-footer CTA, footer, site-wide JSON-LD); each page supplies only its `` content. The painted light/dark CTA and footer are owned by `LandingShell`; never add page-specific closing CTA bands or footer instances.
- 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
+## Styling - draw from the platform's tokens
-The landing page looks like the product. Its visual language is the workspace UI in light mode, not a separate marketing theme.
+The landing page looks like the product. Its visual language is the workspace UI - light by default, dark on request - 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.
+- **Light by default, dark on request.** The landing family follows the theme class on `` (next-themes, storage key `sim-theme`): a first-time visitor gets light, the design baseline, and the footer's `ThemeToggle` switches to dark - the platform's own `.dark` token values from `app/_styles/globals.css`, no separate palette. Tokens flip on their own, so `dark:` variants exist here only to pair the handful of deliberate literals (the `#F8F8F8` paper band, the composer send button, the pale CTA drawing) with their dark value in the same class string - never leave a literal unpaired. Never read the theme in a Server Component; the toggle is the one client reader.
- **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 `` in the root layout). Use the platform text scale (`text-small` = 13px, `text-base` = 15px, etc. - see the `@theme` block in `app/_styles/globals.css`). Don't add new fonts or font CSS variables without explicit direction.
@@ -99,7 +99,7 @@ Absolute imports only in component code (`@/app/(landing)/components/...`); `ind
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:`.
+3. Platform tokens and emcn chrome only - no hex colors, no `--landing-*`. Check the section in dark mode too (footer toggle); any deliberate literal carries its `dark:` pair.
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.
diff --git a/apps/sim/app/(landing)/careers/careers.tsx b/apps/sim/app/(landing)/careers/careers.tsx
index 56c4d2fe194..b9f8d7de184 100644
--- a/apps/sim/app/(landing)/careers/careers.tsx
+++ b/apps/sim/app/(landing)/careers/careers.tsx
@@ -21,8 +21,8 @@ interface CareersProps {
* 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
+ * Both sections share the landing gutter — capped and centered at `max-w-[1728px]`
+ * with the navbar-aligned `px-10 max-md:px-7 max-lg:px-8 max-xl:px-9` so the headline starts on
* the same vertical line as the wordmark. The hero carries the single `
`
* (containing "Sim" and "AI workspace") plus an sr-only product summary for AI
* citation (landing CLAUDE.md → GEO); the roles section owns its own `
Careers at Sim, the open-source AI workspace where teams build, deploy, and manage AI
@@ -70,7 +70,7 @@ export default async function Careers({ searchParams }: CareersProps) {
+ )
+}
diff --git a/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx b/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx
new file mode 100644
index 00000000000..1713a86b88f
--- /dev/null
+++ b/apps/sim/app/(landing)/components/agent-momentum/agent-momentum.test.tsx
@@ -0,0 +1,36 @@
+/**
+ * @vitest-environment node
+ */
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@sim/emcn', () => ({
+ cn: (...values: Array) => values.filter(Boolean).join(' '),
+}))
+
+import { AgentMomentum } from '@/app/(landing)/components/agent-momentum/agent-momentum'
+
+describe('AgentMomentum', () => {
+ it('renders the supplied savings, hours, and builders totals in order', () => {
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain(
+ 'The world’s work is moving to AI agents. Sim gives teams one place to build, deploy, monitor and govern every agent across the business.'
+ )
+ expect(markup).not.toContain('
+
+
+
+ The world’s work is moving to AI agents. Sim gives teams one place to build, deploy,
+ monitor and govern every agent across the business.
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/components/agent-momentum/index.ts b/apps/sim/app/(landing)/components/agent-momentum/index.ts
new file mode 100644
index 00000000000..c03e5d183a7
--- /dev/null
+++ b/apps/sim/app/(landing)/components/agent-momentum/index.ts
@@ -0,0 +1 @@
+export { AgentMomentum } from './agent-momentum'
diff --git a/apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx b/apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx
index bd7ffc46216..73f86fdd585 100644
--- a/apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx
+++ b/apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx
@@ -1,12 +1,21 @@
+import { cn } from '@sim/emcn'
+
+interface ChevronArrowProps {
+ className?: string
+ /** Holds the arrow in its revealed state for the currently previewed menu item. */
+ active?: boolean
+ strokeWidth?: number
+}
+
/**
* The animated chevron used on landing link rows (models, integrations). On
- * `group-hover/link` the leading line draws in and the arrowhead nudges right.
+ * hover or keyboard focus, the leading line draws in and the arrowhead nudges right.
* Decorative, so `aria-hidden`.
*/
-export function ChevronArrow() {
+export function ChevronArrow({ className, active = false, strokeWidth = 1.33 }: ChevronArrowProps) {
return (
)
diff --git a/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx b/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx
index 8ba0d5e00eb..6acc472aaa3 100644
--- a/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx
+++ b/apps/sim/app/(landing)/components/content-author-page/content-author-loading.tsx
@@ -6,7 +6,7 @@ const AUTHOR_POST_SKELETON_COUNT = 4
export function ContentAuthorLoading() {
return (
-
+
@@ -16,7 +16,7 @@ export function ContentAuthorLoading() {
-
diff --git a/apps/sim/app/(landing)/components/cta/cta.module.css b/apps/sim/app/(landing)/components/cta/cta.module.css
new file mode 100644
index 00000000000..58d6c8b3d4e
--- /dev/null
+++ b/apps/sim/app/(landing)/components/cta/cta.module.css
@@ -0,0 +1,36 @@
+/**
+ * A soft central cutout lets painted sky rise alongside the overlapping copy.
+ * The vertical mask dissolves the outer boundaries into the theme canvas;
+ * the landscape retains its full detail below the content.
+ */
+.plate {
+ --plate-mask: linear-gradient(
+ to bottom,
+ rgb(0 0 0 / 0) 0%,
+ rgb(0 0 0 / 0.074) 1.7%,
+ rgb(0 0 0 / 0.259) 3.3%,
+ rgb(0 0 0 / 0.5) 5%,
+ rgb(0 0 0 / 0.741) 6.7%,
+ rgb(0 0 0 / 0.926) 8.3%,
+ rgb(0 0 0 / 1) 10%,
+ rgb(0 0 0 / 1) 80%,
+ rgb(0 0 0 / 0.926) 83.3%,
+ rgb(0 0 0 / 0.741) 86.7%,
+ rgb(0 0 0 / 0.5) 90%,
+ rgb(0 0 0 / 0.259) 93.3%,
+ rgb(0 0 0 / 0.074) 96.7%,
+ rgb(0 0 0 / 0) 100%
+ );
+ --copy-mask: radial-gradient(
+ ellipse 52% 38% at 50% 0%,
+ rgb(0 0 0 / 0) 24%,
+ rgb(0 0 0 / 0.08) 40%,
+ rgb(0 0 0 / 0.35) 58%,
+ rgb(0 0 0 / 0.75) 78%,
+ rgb(0 0 0 / 1) 100%
+ );
+ -webkit-mask-image: var(--plate-mask), var(--copy-mask);
+ mask-image: var(--plate-mask), var(--copy-mask);
+ -webkit-mask-composite: source-in;
+ mask-composite: intersect;
+}
diff --git a/apps/sim/app/(landing)/components/cta/cta.tsx b/apps/sim/app/(landing)/components/cta/cta.tsx
index b82a6bcf7ef..94354db852a 100644
--- a/apps/sim/app/(landing)/components/cta/cta.tsx
+++ b/apps/sim/app/(landing)/components/cta/cta.tsx
@@ -1,40 +1,70 @@
-import { ChipLink } from '@sim/emcn'
-import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants'
+import { cn } from '@sim/emcn'
+import Image from 'next/image'
+import styles from '@/app/(landing)/components/cta/cta.module.css'
+import { HeroCta } from '@/app/(landing)/components/hero-cta'
+import {
+ HOME_TYPE,
+ LANDING_CONTENT_WIDTH,
+ LANDING_GUTTER,
+} from '@/app/(landing)/components/landing-layout'
+
+/** One shared closing statement, with a deliberate line break between sentences. */
+const CTA_HEADLINE = ['Every agent your company runs.', 'All in one place.'] as const
/**
- * 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 `` flex in
- * `landing.tsx`) above it and the `Footer`'s top margin below it. The headline
- * mirrors the hero `
` 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]`.
+ * Painted pre-footer CTA for every marketing page, mounted once by LandingShell.
+ * Theme classes select the matching lazy-loaded painting without client state.
+ * The sky mask keeps the copy clear and the lower edge fades into the footer.
*/
export function Cta() {
return (
-
+
+ )
+}
diff --git a/apps/sim/app/(landing)/components/featured-customer/index.ts b/apps/sim/app/(landing)/components/featured-customer/index.ts
new file mode 100644
index 00000000000..48a3444e25f
--- /dev/null
+++ b/apps/sim/app/(landing)/components/featured-customer/index.ts
@@ -0,0 +1 @@
+export { FeaturedCustomer } from './featured-customer'
diff --git a/apps/sim/app/(landing)/components/features/components/build-callout/components/build-chat-animation/build-chat-animation.tsx b/apps/sim/app/(landing)/components/features/components/build-callout/components/build-chat-animation/build-chat-animation.tsx
index d17b94f807f..f240f860520 100644
--- a/apps/sim/app/(landing)/components/features/components/build-callout/components/build-chat-animation/build-chat-animation.tsx
+++ b/apps/sim/app/(landing)/components/features/components/build-callout/components/build-chat-animation/build-chat-animation.tsx
@@ -243,7 +243,7 @@ export function BuildChatAnimation() {
>
-
+
)}
-
-
+
+
diff --git a/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx b/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx
new file mode 100644
index 00000000000..8c8278aa519
--- /dev/null
+++ b/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx
@@ -0,0 +1,74 @@
+import type { ReactNode } from 'react'
+import { cn } from '@sim/emcn'
+import Link from 'next/link'
+import { LANDING_STAGE_RADIUS } from '@/app/(landing)/components/landing-layout'
+
+interface CoreFeatureCardProps {
+ title: string
+ description: string
+ href: string
+ visual: ReactNode
+ tone?: 'light' | 'mid' | 'dark'
+}
+
+const TONE_CLASSES = {
+ light: 'bg-[var(--surface-3)]',
+ mid: 'bg-[var(--surface-5)]',
+ dark: 'bg-[var(--text-secondary)]',
+} as const
+
+/**
+ * Lets a horizontal gesture over the illustration reach the rail underneath.
+ *
+ * The graphics crop themselves with `overflow-hidden`, which makes every crop
+ * box a scroll container, and the global base rule gives every element
+ * `overscroll-behavior-x: none`. A trackpad swipe or horizontal wheel landing
+ * anywhere on the stage would otherwise stop dead at the first crop it hits
+ * instead of chaining out to the scroller, so the rail only scrolled from the
+ * caption below the stage. Nothing inside the stage scrolls on its own, so
+ * chaining out is always right, and the rail's own `overscroll-x-contain`
+ * still keeps the gesture from running on into the page.
+ */
+const SCROLL_THROUGH = 'overscroll-x-auto [&_*]:overscroll-x-auto'
+
+/**
+ * One homepage product module: a tall product-UI stage followed by a compact,
+ * independently quotable title and description. The whole module is a real
+ * route link, while the illustration remains decorative and non-interactive.
+ */
+export function CoreFeatureCard({
+ title,
+ description,
+ href,
+ visual,
+ tone = 'light',
+}: CoreFeatureCardProps) {
+ return (
+
+
+
{href && linkLabel && (
{linkLabel}
diff --git a/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.test.tsx b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.test.tsx
new file mode 100644
index 00000000000..953b3a8cd13
--- /dev/null
+++ b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.test.tsx
@@ -0,0 +1,193 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, StrictMode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
+import {
+ FeaturesRail,
+ foldScrollLeft,
+} from '@/app/(landing)/components/features/components/features-rail/features-rail'
+
+const CARDS = ['Chat', 'Workflows', 'Tables'] as const
+
+/** Slot pitch the layout stub uses: a card plus the rail's gap. */
+const PITCH = 444
+/** One set of three cards, in the stubbed layout. */
+const SET = CARDS.length * PITCH
+
+function cards() {
+ return CARDS.map((title) => (
+
+ {title}
+
+ ))
+}
+
+const scrollLefts = new WeakMap()
+const originalOffsetLeft = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetLeft')
+const originalScrollLeft = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft')
+
+/**
+ * jsdom has no layout, so slots report a synthetic `offsetLeft` from their
+ * index, and `scrollLeft` round-trips through a map instead of a scrollport.
+ */
+beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, 'offsetLeft', {
+ configurable: true,
+ get(this: HTMLElement) {
+ const parent = this.parentElement
+ if (!parent || !this.hasAttribute('data-copy')) return 0
+ return Array.prototype.indexOf.call(parent.children, this) * PITCH
+ },
+ })
+ Object.defineProperty(Element.prototype, 'scrollLeft', {
+ configurable: true,
+ get(this: HTMLElement) {
+ return scrollLefts.get(this) ?? 0
+ },
+ set(this: HTMLElement, value: number) {
+ scrollLefts.set(this, value)
+ },
+ })
+})
+
+afterAll(() => {
+ if (originalOffsetLeft)
+ Object.defineProperty(HTMLElement.prototype, 'offsetLeft', originalOffsetLeft)
+ if (originalScrollLeft) Object.defineProperty(Element.prototype, 'scrollLeft', originalScrollLeft)
+})
+
+let root: Root | null = null
+let host: HTMLDivElement | null = null
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ host = document.createElement('div')
+ document.body.append(host)
+ root = createRoot(host)
+})
+
+afterEach(() => {
+ act(() => root?.unmount())
+ root = null
+ host?.remove()
+ host = null
+})
+
+function mount(strict = false): HTMLElement {
+ const rail = {cards()}
+ act(() => root?.render(strict ? {rail} : rail))
+ const el = host?.querySelector('[aria-label="Core Sim features"]')
+ if (!el) throw new Error('rail did not render')
+ return el
+}
+
+function scrollTo(rail: HTMLElement, left: number): number {
+ rail.scrollLeft = left
+ rail.dispatchEvent(new Event('scroll'))
+ return rail.scrollLeft
+}
+
+describe('foldScrollLeft', () => {
+ it('leaves the home range alone and folds by exactly one set width', () => {
+ expect(foldScrollLeft(1000, 1000)).toBe(1000)
+ expect(foldScrollLeft(500, 1000)).toBe(500)
+ expect(foldScrollLeft(499, 1000)).toBe(1499)
+ expect(foldScrollLeft(1500, 1000)).toBe(500)
+ expect(foldScrollLeft(2600, 1000)).toBe(600)
+ expect(foldScrollLeft(-200, 1000)).toBe(800)
+ })
+
+ it('does nothing without a measurable set', () => {
+ expect(foldScrollLeft(42, 0)).toBe(42)
+ })
+})
+
+describe('FeaturesRail', () => {
+ it('server-renders the finite rail once, with the scroll chrome', () => {
+ const html = renderToStaticMarkup(
+ {cards()}
+ )
+
+ expect(html).toContain('aria-label="Core Sim features"')
+ expect(html).toContain('overflow-x-auto')
+ expect(html).not.toContain('snap-')
+ expect(html.match(/data-copy="home"/g)).toHaveLength(3)
+ expect(html).not.toContain('data-copy="lead"')
+ expect(html).not.toContain('data-copy="tail"')
+ })
+
+ it('loops after hydration with clones off the tab order and accessibility tree', () => {
+ const rail = mount()
+
+ expect(rail.querySelectorAll('[data-copy="lead"]')).toHaveLength(3)
+ expect(rail.querySelectorAll('[data-copy="home"]')).toHaveLength(3)
+ expect(rail.querySelectorAll('[data-copy="tail"]')).toHaveLength(3)
+ expect(rail.querySelectorAll('[data-copy="lead"][aria-hidden="true"]')).toHaveLength(3)
+ expect(rail.querySelectorAll('[data-copy="tail"][aria-hidden="true"]')).toHaveLength(3)
+ expect(rail.querySelectorAll('[data-copy="home"][aria-hidden]')).toHaveLength(0)
+
+ const cloneLinks = rail.querySelectorAll('[data-copy="lead"] a, [data-copy="tail"] a')
+ expect(cloneLinks).toHaveLength(6)
+ for (const link of cloneLinks) {
+ expect(link.getAttribute('tabindex')).toBe('-1')
+ }
+ expect(rail.querySelectorAll('[data-copy="home"] a[tabindex]')).toHaveLength(0)
+ expect(rail.children).toHaveLength(9)
+ })
+
+ it('rests on the middle copy after hydration, once, even when Strict Mode reruns the effect', () => {
+ expect(mount().scrollLeft).toBe(SET)
+ act(() => root?.unmount())
+ root = createRoot(host as HTMLDivElement)
+ expect(mount(true).scrollLeft).toBe(SET)
+ })
+
+ it('drags with the mouse, scrolling by the pointer delta and swallowing the click', () => {
+ const rail = mount()
+ const link = rail.querySelector('[data-copy="home"] a')
+ if (!link) throw new Error('no home link')
+ /** jsdom has no PointerEvent; a MouseEvent carrying the pointer fields is what the handlers read. */
+ const pointer = (type: string, clientX: number) => {
+ const event = new MouseEvent(type, { bubbles: true, button: 0, clientX })
+ Object.defineProperties(event, { pointerType: { value: 'mouse' }, pointerId: { value: 1 } })
+ return event
+ }
+
+ rail.scrollLeft = SET
+ link.dispatchEvent(pointer('pointerdown', 300))
+ link.dispatchEvent(pointer('pointermove', 303))
+ expect(rail.scrollLeft).toBe(SET)
+ link.dispatchEvent(pointer('pointermove', 260))
+ expect(rail.scrollLeft).toBe(SET + 40)
+ expect(rail.dataset.dragging).toBe('')
+ link.dispatchEvent(pointer('pointerup', 260))
+ expect(rail.dataset.dragging).toBeUndefined()
+
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true })
+ link.dispatchEvent(click)
+ expect(click.defaultPrevented).toBe(true)
+
+ let swallowed: boolean | null = null
+ link.addEventListener(
+ 'click',
+ (event) => {
+ swallowed = event.defaultPrevented
+ event.preventDefault()
+ },
+ { once: true }
+ )
+ link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ expect(swallowed).toBe(false)
+ })
+
+ it('folds the position back into the middle copy as the user scrolls past it', () => {
+ const rail = mount()
+
+ expect(scrollTo(rail, SET + 200)).toBe(SET + 200)
+ expect(scrollTo(rail, SET * 1.5 + 100)).toBe(SET * 0.5 + 100)
+ expect(scrollTo(rail, SET * 0.5 - 60)).toBe(SET * 1.5 - 60)
+ })
+})
diff --git a/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx
new file mode 100644
index 00000000000..b48a31f08df
--- /dev/null
+++ b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx
@@ -0,0 +1,243 @@
+'use client'
+
+import { Children, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { cn } from '@sim/emcn'
+import { EdgeFade } from '@/app/(landing)/components/shared/edge-fade'
+
+/**
+ * Breaks the rail out of the 10/12 inset to the section's edges. The section
+ * is a size container (`container-type: inline-size`), so `50cqw - 50%` is
+ * exactly the inset's offset from the section edge: as a negative margin it
+ * spans the rail edge to edge, and the same value as padding keeps the first
+ * card aligned with the heading while cards clip at the viewport rather than
+ * at the inset.
+ */
+const RAIL_BLEED = '-mx-[calc(50cqw_-_50%)] px-[calc(50cqw_-_50%)]'
+
+/**
+ * The edge-fade overlay, stretched over the same edge-to-edge span the rail
+ * bleeds to. The overlay is positioned against the rail's wrapper, which keeps
+ * the inset width, so `50%` here is the same half-inset {@link RAIL_BLEED}
+ * subtracts - pulling each side out to the section edge. Above the cards
+ * (below the navbar's `z-50`) and transparent to the pointer, so the fade only
+ * ever paints.
+ */
+const RAIL_EDGE_SPAN =
+ 'pointer-events-none absolute inset-y-0 z-10 left-[calc(50%_-_50cqw)] right-[calc(50%_-_50cqw)]'
+
+/** One card slot: fixed responsive width. */
+const SLOT_CLASS = 'w-[min(78vw,420px)] shrink-0 max-sm:w-[84vw]'
+
+/** Pointer travel before a mouse press on the rail becomes a drag instead of a click. */
+const DRAG_THRESHOLD_PX = 6
+
+/**
+ * A fold moves the position by a whole set width, so anything smaller than
+ * this is floating-point noise from re-deriving an in-range position.
+ */
+const FOLD_TOLERANCE = 1
+
+type Copy = 'lead' | 'home' | 'tail'
+
+/**
+ * Folds a scroll position back into the loop's home range. Three copies of the
+ * set sit side by side; the position is kept between half a set and a set and
+ * a half in, so a full set of cards is always waiting on either side and every
+ * fold - an exact set width, onto identical content - is invisible. A
+ * `setWidth` of 0 means the loop is not measurable (no layout), so the position
+ * is left alone.
+ */
+export function foldScrollLeft(scrollLeft: number, setWidth: number): number {
+ if (setWidth <= 0) return scrollLeft
+ const from = setWidth / 2
+ const offset = (((scrollLeft - from) % setWidth) + setWidth) % setWidth
+ return from + offset
+}
+
+interface SlotsProps {
+ copy: Copy
+ cards: ReactNode[]
+}
+
+/**
+ * One copy of the set as flat flex children, so the gap between copies equals
+ * the gap between cards and the loop's period is exactly one set width. Clones
+ * stay clickable but leave the accessibility tree.
+ */
+function Slots({ copy, cards }: SlotsProps) {
+ const clone = copy !== 'home'
+ return (
+ <>
+ {cards.map((card, index) => (
+
+ {card}
+
+ ))}
+ >
+ )
+}
+
+interface FeaturesRailProps {
+ /** Accessible name of the scrolling region. */
+ label: string
+ /**
+ * The cards, in order. Each becomes one slot; once JS runs the whole set is
+ * cloned on both sides so the rail loops.
+ */
+ children: ReactNode
+}
+
+/**
+ * The homepage product rail: native horizontal scrolling that never ends.
+ *
+ * The server renders the set once, so the HTML - and any visit without JS - is
+ * the plain finite rail with the first card under the heading. After hydration
+ * the set is cloned once on each side, the scroll position jumps one set width
+ * before paint so nothing visibly moves (folded, so Strict Mode's second run of
+ * the effect lands on the same spot), and a passive scroll listener folds the
+ * position back into the middle copy whenever it drifts half a set past it. The fold is an exact set width onto identical content, so the space
+ * left of the first card is always the tail of the set and scrolling in either
+ * direction never meets an end.
+ *
+ * Wheel, trackpad, touch, and keyboard all drive the native scroller with no
+ * snapping to fight them, so the rail scrolls the same whether or not the
+ * pointer is over a card. A mouse can also drag the rail: past a small
+ * threshold the press scrolls by the pointer's movement (as deltas, so a fold
+ * mid-drag is harmless), the click that would follow is swallowed, and native
+ * link and image drags are off. Clones stay clickable but sit outside the tab
+ * order and the accessibility tree, so keyboard and screen-reader users meet
+ * each product exactly once.
+ *
+ * Both screen edges blur and fade into the page ground ({@link EdgeFade}), the
+ * same treatment the product-demo stage wears, so a card leaving the rail
+ * softens away instead of being cut off at the viewport.
+ */
+export function FeaturesRail({ label, children }: FeaturesRailProps) {
+ const railRef = useRef(null)
+ const setWidthRef = useRef(0)
+ const [looping, setLooping] = useState(false)
+ const cards = Children.toArray(children)
+
+ useEffect(() => {
+ setLooping(true)
+ }, [])
+
+ useLayoutEffect(() => {
+ if (!looping) return
+ const rail = railRef.current
+ if (!rail) return
+
+ const measure = () => {
+ const lead = rail.querySelector('[data-copy="lead"]')
+ const home = rail.querySelector('[data-copy="home"]')
+ setWidthRef.current = lead && home ? home.offsetLeft - lead.offsetLeft : 0
+ }
+ const fold = () => {
+ const next = foldScrollLeft(rail.scrollLeft, setWidthRef.current)
+ if (Math.abs(next - rail.scrollLeft) > FOLD_TOLERANCE) rail.scrollLeft = next
+ }
+
+ for (const link of rail.querySelectorAll(
+ '[data-copy="lead"] a, [data-copy="tail"] a'
+ )) {
+ link.tabIndex = -1
+ }
+ measure()
+ rail.scrollLeft = foldScrollLeft(rail.scrollLeft + setWidthRef.current, setWidthRef.current)
+ rail.addEventListener('scroll', fold, { passive: true })
+ const observer =
+ typeof ResizeObserver === 'undefined'
+ ? undefined
+ : new ResizeObserver(() => {
+ measure()
+ fold()
+ })
+ observer?.observe(rail)
+
+ return () => {
+ rail.removeEventListener('scroll', fold)
+ observer?.disconnect()
+ }
+ }, [looping])
+
+ useEffect(() => {
+ const rail = railRef.current
+ if (!rail) return
+ let pointerId: number | null = null
+ let startX = 0
+ let lastX = 0
+ let dragged = false
+
+ const onPointerDown = (event: PointerEvent) => {
+ if (event.pointerType !== 'mouse' || event.button !== 0) return
+ pointerId = event.pointerId
+ startX = event.clientX
+ lastX = event.clientX
+ dragged = false
+ }
+ const onPointerMove = (event: PointerEvent) => {
+ if (event.pointerId !== pointerId) return
+ if (!dragged) {
+ if (Math.abs(event.clientX - startX) < DRAG_THRESHOLD_PX) return
+ dragged = true
+ rail.dataset.dragging = ''
+ rail.setPointerCapture?.(event.pointerId)
+ }
+ rail.scrollLeft -= event.clientX - lastX
+ lastX = event.clientX
+ }
+ const onPointerEnd = (event: PointerEvent) => {
+ if (event.pointerId !== pointerId) return
+ pointerId = null
+ delete rail.dataset.dragging
+ if (rail.hasPointerCapture?.(event.pointerId)) rail.releasePointerCapture(event.pointerId)
+ }
+ const onClick = (event: MouseEvent) => {
+ if (!dragged) return
+ dragged = false
+ event.preventDefault()
+ event.stopPropagation()
+ }
+ const onDragStart = (event: DragEvent) => event.preventDefault()
+
+ rail.addEventListener('pointerdown', onPointerDown)
+ rail.addEventListener('pointermove', onPointerMove)
+ rail.addEventListener('pointerup', onPointerEnd)
+ rail.addEventListener('pointercancel', onPointerEnd)
+ rail.addEventListener('click', onClick, true)
+ rail.addEventListener('dragstart', onDragStart)
+ return () => {
+ rail.removeEventListener('pointerdown', onPointerDown)
+ rail.removeEventListener('pointermove', onPointerMove)
+ rail.removeEventListener('pointerup', onPointerEnd)
+ rail.removeEventListener('pointercancel', onPointerEnd)
+ rail.removeEventListener('click', onClick, true)
+ rail.removeEventListener('dragstart', onDragStart)
+ }
+ }, [])
+
+ return (
+
+
+ {looping && }
+
+ {looping && }
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/components/features/components/features-rail/index.ts b/apps/sim/app/(landing)/components/features/components/features-rail/index.ts
new file mode 100644
index 00000000000..8283070c3a5
--- /dev/null
+++ b/apps/sim/app/(landing)/components/features/components/features-rail/index.ts
@@ -0,0 +1 @@
+export { FeaturesRail } from './features-rail'
diff --git a/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx b/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx
index 75660444a55..4763bb1b9ea 100644
--- a/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx
+++ b/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx
@@ -18,13 +18,12 @@ import { CapturedPlatformSurface } from '@/app/(landing)/components/features/com
* 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).
+ * 386px fixed copy column, desktop only])`. Gutter is the shared landing
+ * gutter. 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 `1728px`
+ * (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 (
@@ -35,7 +34,7 @@ export function IntegrationsCallout() {
>
diff --git a/apps/sim/app/(landing)/components/features/features.test.tsx b/apps/sim/app/(landing)/components/features/features.test.tsx
new file mode 100644
index 00000000000..29d3ae24ad2
--- /dev/null
+++ b/apps/sim/app/(landing)/components/features/features.test.tsx
@@ -0,0 +1,31 @@
+/**
+ * @vitest-environment node
+ */
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import { Features } from '@/app/(landing)/components/features/features'
+
+const MODULES = [
+ { title: 'CLI', href: 'https://docs.sim.ai/cli' },
+ { title: 'Workflows', href: '/workflows' },
+ { title: 'Knowledge Base', href: '/knowledge' },
+ { title: 'Tables', href: '/tables' },
+ { title: 'Files', href: '/files' },
+ { title: 'Logs', href: '/logs' },
+] as const
+
+describe('Features', () => {
+ it('renders every core Sim module as a crawlable tall card', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('Everything AI agents need to do real work')
+ expect(html).toContain('aspect-[5/6]')
+ expect(html).toContain('rounded-[12px]')
+ expect(html).toContain('overflow-x-auto')
+
+ for (const module of MODULES) {
+ expect(html).toContain(`>${module.title}`)
+ expect(html).toContain(`href="${module.href}"`)
+ }
+ })
+})
diff --git a/apps/sim/app/(landing)/components/features/features.tsx b/apps/sim/app/(landing)/components/features/features.tsx
index 498ab5b2ced..3c2691c86a3 100644
--- a/apps/sim/app/(landing)/components/features/features.tsx
+++ b/apps/sim/app/(landing)/components/features/features.tsx
@@ -1,94 +1,99 @@
-import { BuildCallout } from '@/app/(landing)/components/features/components/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'
+import { cn } from '@sim/emcn'
+import { CoreFeatureCard } from '@/app/(landing)/components/features/components/core-feature-card'
+import { FeaturesRail } from '@/app/(landing)/components/features/components/features-rail'
+import {
+ HOME_INSET,
+ HOME_TYPE,
+ LANDING_CONTENT_WIDTH,
+ LANDING_GUTTER,
+} from '@/app/(landing)/components/landing-layout'
+import { CliGraphic } from '@/app/(landing)/components/shared/cli-graphic/cli-graphic'
+import { FileLibraryGraphic } from '@/app/(landing)/files/components/feature-graphics/file-library-graphic'
+import { ConnectorSyncGraphic } from '@/app/(landing)/knowledge/components/feature-graphics/connector-sync-graphic'
+import { RunTraceGraphic } from '@/app/(landing)/logs/components/feature-graphics'
+import { TableGridGraphic } from '@/app/(landing)/tables/components/feature-graphics'
+import { WorkflowCanvasGraphic } from '@/app/(landing)/workflows/components/feature-graphics'
/**
- * 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 `
` is `sr-only` - each beat carries its own visible `
`,
- * 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 `` 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.
+ * Homepage product suite - six tall editorial modules built from the same
+ * product-faithful UI illustrations used across Sim's platform pages.
+ */
+const CORE_FEATURES = [
+ {
+ title: 'CLI',
+ description: 'Use Sim from Claude Code or your terminal to build, run, and manage agents.',
+ href: 'https://docs.sim.ai/cli',
+ tone: 'mid',
+ visual: ,
+ },
+ {
+ title: 'Workflows',
+ description: 'Sim connects blocks, models, and integrations into agent logic.',
+ href: '/workflows',
+ tone: 'light',
+ visual: ,
+ },
+ {
+ title: 'Knowledge Base',
+ description: 'Sim gives every agent trusted memory from synced sources.',
+ href: '/knowledge',
+ tone: 'mid',
+ visual: ,
+ },
+ {
+ title: 'Tables',
+ description: 'Sim stores the structured data agents read and update between runs.',
+ href: '/tables',
+ tone: 'light',
+ visual: ,
+ },
+ {
+ title: 'Files',
+ description: 'Sim keeps team uploads and agent outputs in one shared store.',
+ href: '/files',
+ tone: 'mid',
+ visual: ,
+ },
+ {
+ title: 'Logs',
+ description: 'Sim traces every agent run block by block, including failures.',
+ href: '/logs',
+ tone: 'light',
+ visual: ,
+ },
+] as const
+
+/**
+ * Six product modules on one horizontally scrolling rail. The heading keeps
+ * the page measure; the rail runs flush to the screen edges and loops - see
+ * {@link FeaturesRail}. The section is the rail's size container.
*/
export function Features() {
return (
-
-
- Integrate your tools, give Sim context, build agents, and monitor every run.
-
-
-
- {/* Integrate: bring your stack in. */}
-
-
-
+
+
+
+
+ Everything AI agents need to do real work
+
- {/* Context: store data semantically. */}
-
-
-
-
- {/* Build: wire agent logic in the visual builder. */}
-
-
-
-
- {/* Monitor: watch every run. */}
-
-
-
+
+ {CORE_FEATURES.map((feature) => (
+
+ ))}
+
+
-
- {/* 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). */}
-
)
}
diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx
new file mode 100644
index 00000000000..a39fb279919
--- /dev/null
+++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx
@@ -0,0 +1,128 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { WORDMARK_PATHS } from '@/lib/branding/wordmark'
+import { FooterWordmarkLoop } from '@/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop'
+
+const SHAPES = ['metaballs', 'relay', 'compass', 'corners', 'burst', 'squeeze', 'thinking'] as const
+
+/** One full pass of the master film, in ms. */
+const CYCLE_MS = 17_100
+
+let pending: FrameRequestCallback[] = []
+let clock = 0
+let root: Root | null = null
+let host: HTMLDivElement | null = null
+
+/**
+ * Drives the captured frame callbacks to `ms` on the loop's own clock in 50ms
+ * steps - under the loop's 100ms per-frame cap, so no choreography is skipped.
+ */
+function advanceTo(ms: number): void {
+ while (clock < ms) {
+ clock = Math.min(clock + 50, ms)
+ const frame = pending.shift()
+ if (!frame) throw new Error('the loop stopped requesting frames')
+ const now = clock
+ act(() => frame(now))
+ }
+}
+
+function attr(selector: string, name: string): string | null {
+ return host?.querySelector(selector)?.getAttribute(name) ?? null
+}
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ pending = []
+ clock = 0
+ const stubs = {
+ requestAnimationFrame: (cb: FrameRequestCallback) => pending.push(cb),
+ cancelAnimationFrame: () => {
+ pending = []
+ },
+ matchMedia: () => ({
+ matches: false,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }),
+ }
+ for (const [name, value] of Object.entries(stubs)) {
+ vi.stubGlobal(name, value)
+ Object.assign(window, { [name]: value })
+ }
+ host = document.createElement('div')
+ document.body.append(host)
+ root = createRoot(host)
+ act(() => root?.render())
+ const anchor = pending.shift()
+ if (!anchor) throw new Error('the loop never started')
+ act(() => anchor(0))
+})
+
+afterEach(() => {
+ act(() => root?.unmount())
+ root = null
+ host?.remove()
+ host = null
+ vi.unstubAllGlobals()
+})
+
+describe('FooterWordmarkLoop', () => {
+ it('server-renders the crisp wordmark as the resting frame', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('aria-hidden="true"')
+ expect(html).toContain('data-stage="wm" opacity="1"')
+ expect(html).toContain('data-stage="orb" opacity="0"')
+ expect(html).toContain('stdDeviation="0.55"')
+ for (const shape of SHAPES) {
+ expect(html).toContain(`data-stage="${shape}" opacity="0"`)
+ }
+ for (const d of WORDMARK_PATHS) {
+ expect(html).toContain(`d="${d}"`)
+ }
+ })
+
+ it('plays the master timeline: wordmark, orb, the seven shapes, orb, wordmark', () => {
+ expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000')
+ expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550')
+
+ advanceTo(2700)
+ expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000')
+ expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000')
+ expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000')
+
+ advanceTo(3900)
+ expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000')
+ expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000')
+ expect(attr('[data-anim="metaballsA"]', 'transform')).not.toBe('translate(0.000 0.000)')
+
+ advanceTo(7000)
+ expect(attr('[data-stage="compass"]', 'opacity')).toBe('1.0000')
+ expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('0.0000')
+
+ advanceTo(9700)
+ expect(attr('[data-stage="burst"]', 'opacity')).toBe('1.0000')
+
+ advanceTo(16000)
+ expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000')
+ expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000')
+ expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550')
+
+ advanceTo(CYCLE_MS + 2700)
+ expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000')
+ expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000')
+ })
+
+ it('stops requesting frames on unmount', () => {
+ advanceTo(500)
+ act(() => root?.unmount())
+ root = null
+ expect(pending).toHaveLength(0)
+ })
+})
diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx
new file mode 100644
index 00000000000..4d919051653
--- /dev/null
+++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx
@@ -0,0 +1,637 @@
+'use client'
+
+import { type CSSProperties, useEffect, useId, useRef } from 'react'
+import { cn } from '@sim/emcn'
+import { WORDMARK_PATHS, WORDMARK_VIEW_BOX } from '@/lib/branding/wordmark'
+
+/**
+ * The mark's ink: the platform's thinking-loader gradient tokens, so it follows
+ * the theme. Light resolves to the locked `GOO_GRADIENT` recipe; dark lifts it
+ * to the loader's light-on-dark material. Set through `style`, where `var()`
+ * is unambiguous, rather than as a presentation attribute.
+ */
+const INK_STOP_INNER = { stopColor: 'var(--thinking-ink-inner)' } as const satisfies CSSProperties
+const INK_STOP_OUTER = { stopColor: 'var(--thinking-ink-outer)' } as const satisfies CSSProperties
+
+/**
+ * The seven thinking-loader shapes in play order, each with its hold in ms.
+ * Order, holds, and every timing constant below are the master's - the
+ * "Sim loader wordmark" film's deterministic generator - so the footer plays
+ * the same choreography frame for frame.
+ */
+const SHAPES = [
+ ['metaballs', 2000],
+ ['relay', 1300],
+ ['compass', 2000],
+ ['corners', 800],
+ ['burst', 1300],
+ ['squeeze', 1200],
+ ['thinking', 2000],
+] as const
+
+type ShapeKey = (typeof SHAPES)[number][0]
+type StageKey = ShapeKey | 'orb' | 'wm'
+
+/** Crossfade between two consecutive shapes. */
+const MORPH = 500
+/** Liquid morph between the wordmark and the orb, on each side of the cycle. */
+const MORPH_LOGO = 1200
+/** Opening hold on the crisp wordmark. */
+const LOGO_HOLD = 1300
+/** Orb settle on either side of the shape cycle. */
+const ORB_BEAT = 450
+/** Closing hold on the wordmark before the loop wraps back to the opening hold. */
+const HOLD_LOGO_END = 1700
+const TAIL = 200
+/** Goo blur while liquid (through the cycle) and while crisp (the wordmark). */
+const GOO_HI = 5
+const GOO_LO = 0.55
+/**
+ * Shapes that restart from compact when they appear and play exactly one pulse
+ * of this many ms (just under a loop, so the dots reach the edge without
+ * snapping back) instead of free-running on the shared clock.
+ */
+const LOCAL_PULSE: Partial> = { burst: 770 }
+/**
+ * Longest step the clock advances per frame. A background tab or a stalled
+ * frame resumes mid-choreography instead of skipping ahead.
+ */
+const MAX_FRAME_STEP = 100
+
+const T_LOGO_HOLD_END = LOGO_HOLD
+const T_INTRO_END = T_LOGO_HOLD_END + MORPH_LOGO
+const FIRST_SHAPE_START = T_INTRO_END + ORB_BEAT
+
+/** Lays the shapes end to end from the first shape's start. */
+function buildShapeWindows(): {
+ windows: Record
+ end: number
+} {
+ const windows: Partial> = {}
+ let cursor = FIRST_SHAPE_START
+ for (const [key, hold] of SHAPES) {
+ windows[key] = [cursor, cursor + hold]
+ cursor += hold
+ }
+ return { windows: windows as Record, end: cursor }
+}
+
+const { windows: SHAPE_WINDOWS, end: SHAPES_END } = buildShapeWindows()
+const T_OUTRO_START = SHAPES_END + ORB_BEAT
+const T_OUTRO_END = T_OUTRO_START + MORPH_LOGO
+/** One full pass: wordmark → orb → seven shapes → orb → wordmark. */
+const CYCLE_MS = T_OUTRO_END + HOLD_LOGO_END + TAIL
+
+interface Track {
+ /** Period in ms. */
+ dur: number
+ /** Play backwards on odd iterations. */
+ alt?: boolean
+ /** Linear timing instead of the CSS default ease. */
+ lin?: boolean
+ /** `[progress, x, y]` keyframes in stage units. */
+ stops: ReadonlyArray
+ /** Optional `[progress, opacity]` keyframes. */
+ op?: ReadonlyArray
+}
+
+/** Per-element motion tracks - the loader's CSS keyframes, evaluated analytically. */
+const TRACKS = {
+ metaballsA: {
+ dur: 1000,
+ alt: true,
+ stops: [
+ [0, 0, 0],
+ [0.9, 28, 0],
+ [1, 28, 0],
+ ],
+ },
+ metaballsB: {
+ dur: 1000,
+ alt: true,
+ stops: [
+ [0, 0, 0],
+ [0.9, -28, 0],
+ [1, -28, 0],
+ ],
+ },
+ relayBall: {
+ dur: 1300,
+ lin: true,
+ stops: [
+ [0, 0, 0],
+ [1, 58, 0],
+ ],
+ op: [
+ [0, 0],
+ [0.2, 1],
+ [0.78, 1],
+ [1, 0],
+ ],
+ },
+ compassMover: {
+ dur: 2000,
+ stops: [
+ [0, 0, 0],
+ [0.25, 27, 27],
+ [0.5, 0, 54],
+ [0.75, -27, 27],
+ [1, 0, 0],
+ ],
+ },
+ cornersA: {
+ dur: 800,
+ stops: [
+ [0, 0, 0],
+ [1, 46, 0],
+ ],
+ },
+ cornersB: {
+ dur: 800,
+ stops: [
+ [0, 0, 0],
+ [1, 0, 46],
+ ],
+ },
+ cornersC: {
+ dur: 800,
+ stops: [
+ [0, 0, 0],
+ [1, -46, 0],
+ ],
+ },
+ cornersD: {
+ dur: 800,
+ stops: [
+ [0, 0, 0],
+ [1, 0, -46],
+ ],
+ },
+ burstUp: {
+ dur: 800,
+ stops: [
+ [0, 0, 0],
+ [1, 0, -50],
+ ],
+ },
+ burstDown: {
+ dur: 800,
+ stops: [
+ [0, 0, 0],
+ [1, 0, 50],
+ ],
+ },
+ burstLeft: {
+ dur: 800,
+ stops: [
+ [0, 0, 0],
+ [1, -50, 0],
+ ],
+ },
+ burstRight: {
+ dur: 800,
+ stops: [
+ [0, 0, 0],
+ [1, 50, 0],
+ ],
+ },
+ squeezeBarL: {
+ dur: 600,
+ alt: true,
+ stops: [
+ [0, 0, 0],
+ [0.3, 0, 0],
+ [1, 10, 0],
+ ],
+ },
+ squeezeBarR: {
+ dur: 600,
+ alt: true,
+ stops: [
+ [0, 0, 0],
+ [0.3, 0, 0],
+ [1, -10, 0],
+ ],
+ },
+ thinkA: {
+ dur: 1600,
+ alt: true,
+ stops: [
+ [0, 0, 0],
+ [1, -20, -14],
+ ],
+ },
+ thinkB: {
+ dur: 1900,
+ alt: true,
+ stops: [
+ [0, 0, 0],
+ [1, 22, -10],
+ ],
+ },
+ thinkC: {
+ dur: 1300,
+ alt: true,
+ stops: [
+ [0, 0, 0],
+ [1, 2, 22],
+ ],
+ },
+} as const satisfies Record
+
+type AnimKey = keyof typeof TRACKS
+
+const clamp01 = (x: number): number => Math.max(0, Math.min(1, x))
+
+/** A CSS `cubic-bezier` timing function, solved for `x` by Newton iteration. */
+function cubicBezier(
+ p1x: number,
+ p1y: number,
+ p2x: number,
+ p2y: number
+): (progress: number) => number {
+ const cx = 3 * p1x
+ const bx = 3 * (p2x - p1x) - cx
+ const ax = 1 - cx - bx
+ const cy = 3 * p1y
+ const by = 3 * (p2y - p1y) - cy
+ const ay = 1 - cy - by
+ const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t
+ const sampleY = (t: number) => ((ay * t + by) * t + cy) * t
+ const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx
+ return (progress) => {
+ let t = progress
+ for (let i = 0; i < 8; i++) {
+ const error = sampleX(t) - progress
+ if (Math.abs(error) < 1e-6) break
+ const slope = slopeX(t)
+ if (Math.abs(slope) < 1e-6) break
+ t -= error / slope
+ }
+ return sampleY(clamp01(t))
+ }
+}
+
+/** The CSS default `ease`. */
+const EASE = cubicBezier(0.25, 0.1, 0.25, 1)
+const LINEAR = (progress: number): number => progress
+
+/** Hermite smoothstep from `a` to `b`. */
+function smooth(a: number, b: number, x: number): number {
+ const t = clamp01((x - a) / (b - a))
+ return t * t * (3 - 2 * t)
+}
+
+/** Opacity envelope: rises over `[t0, t1]`, holds, falls over `[t2, t3]`. */
+function envelope(t: number, t0: number, t1: number, t2: number, t3: number): number {
+ return Math.min(smooth(t0, t1, t), 1 - smooth(t2, t3, t))
+}
+
+/** The keyframe pair around `frac` and the clamped progress between them. */
+function segment(
+ stops: ReadonlyArray,
+ frac: number
+): { a: T; b: T; p: number } {
+ let a = stops[0]
+ let b = stops[stops.length - 1]
+ for (let i = 0; i < stops.length - 1; i++) {
+ if (frac >= stops[i][0] && frac <= stops[i + 1][0]) {
+ a = stops[i]
+ b = stops[i + 1]
+ break
+ }
+ }
+ const span = b[0] - a[0] || 1
+ return { a, b, p: clamp01((frac - a[0]) / span) }
+}
+
+interface Sample {
+ x: number
+ y: number
+ opacity: number
+}
+
+/** Position and opacity of a track at `at` ms on its own clock. */
+function sampleTrack(track: Track, at: number): Sample {
+ const raw = at / track.dur
+ const iteration = Math.floor(raw)
+ let frac = raw - iteration
+ if (track.alt && iteration % 2 === 1) frac = 1 - frac
+ const { a, b, p } = segment(track.stops, frac)
+ const eased = (track.lin ? LINEAR : EASE)(p)
+ const x = a[1] + (b[1] - a[1]) * eased
+ const y = a[2] + (b[2] - a[2]) * eased
+ let opacity = 1
+ if (track.op) {
+ const o = segment(track.op, frac)
+ opacity = o.a[1] + (o.b[1] - o.a[1]) * o.p
+ }
+ return { x, y, opacity }
+}
+
+/**
+ * The wordmark's width inside the 100-unit stage - the generator's 816×392
+ * mark at 0.11164, so the word sits at the same size relative to the orb.
+ */
+const WORDMARK_WIDTH = 91.1
+const WORDMARK_SCALE = WORDMARK_WIDTH / WORDMARK_VIEW_BOX.width
+const WORDMARK_HEIGHT = WORDMARK_VIEW_BOX.height * WORDMARK_SCALE
+const WORDMARK_X = (100 - WORDMARK_WIDTH) / 2
+const WORDMARK_Y = (100 - WORDMARK_HEIGHT) / 2
+const WORDMARK_CX = WORDMARK_VIEW_BOX.width / 2
+const WORDMARK_CY = WORDMARK_VIEW_BOX.height / 2
+
+const round = (n: number): string => n.toFixed(3)
+
+interface AnimatedNode {
+ el: SVGGraphicsElement
+ track: Track
+ stage: ShapeKey
+}
+
+interface StageNode {
+ el: SVGGElement
+ key: StageKey
+}
+
+/**
+ * Paints one frame of the choreography at `t` ms into the cycle by writing
+ * SVG attributes directly - no React render per frame.
+ */
+function paintFrame(
+ t: number,
+ blur: SVGFEGaussianBlurElement,
+ stages: StageNode[],
+ anims: AnimatedNode[]
+): void {
+ for (const { el, track, stage } of anims) {
+ const pulse = LOCAL_PULSE[stage]
+ const clock =
+ pulse === undefined
+ ? t
+ : Math.max(0, Math.min(t - (SHAPE_WINDOWS[stage][0] + MORPH / 2), pulse))
+ const sample = sampleTrack(track, clock)
+ el.setAttribute('transform', `translate(${round(sample.x)} ${round(sample.y)})`)
+ if (track.op) el.setAttribute('opacity', sample.opacity.toFixed(4))
+ }
+
+ const introLogo = 1 - smooth(T_LOGO_HOLD_END, T_INTRO_END, t)
+ const outroLogo = smooth(T_OUTRO_START, T_OUTRO_END, t)
+ const logo = Math.max(introLogo, outroLogo)
+ const orbIn = envelope(
+ t,
+ T_LOGO_HOLD_END,
+ T_INTRO_END,
+ FIRST_SHAPE_START - MORPH / 2,
+ FIRST_SHAPE_START + MORPH / 2
+ )
+ const orbOut = envelope(
+ t,
+ SHAPES_END - MORPH / 2,
+ SHAPES_END + MORPH / 2,
+ T_OUTRO_START,
+ T_OUTRO_END
+ )
+ const orb = Math.max(orbIn, orbOut)
+
+ for (const { el, key } of stages) {
+ let opacity: number
+ if (key === 'wm') opacity = logo
+ else if (key === 'orb') opacity = orb
+ else {
+ const [start, end] = SHAPE_WINDOWS[key]
+ opacity = envelope(t, start - MORPH / 2, start + MORPH / 2, end - MORPH / 2, end + MORPH / 2)
+ }
+ el.setAttribute('opacity', opacity.toFixed(4))
+ }
+
+ const liquid = Math.min(
+ smooth(T_LOGO_HOLD_END, T_INTRO_END, t),
+ 1 - smooth(T_OUTRO_START, T_OUTRO_END, t)
+ )
+ const deviation = round(GOO_LO + (GOO_HI - GOO_LO) * liquid)
+ if (blur.getAttribute('stdDeviation') !== deviation) blur.setAttribute('stdDeviation', deviation)
+}
+
+interface FooterWordmarkLoopProps {
+ /** Layout only - margins and alignment. The mark owns its size and chrome. */
+ className?: string
+}
+
+/**
+ * The footer's closing brand beat: the "Sim loader wordmark" film, live. The
+ * crisp `sim` wordmark goo-melts into the thinking loader's orb, cycles through
+ * all seven loader shapes with gooey morphs between them, settles back to the
+ * orb, and liquid-morphs back into the wordmark - then holds and repeats.
+ * Same geometry, goo filter, ink gradient, and timeline as the master
+ * generator and the product's `ThinkingLoader`, drawn in one 100-unit SVG
+ * stage that scales with the viewport.
+ *
+ * The stage reserves a 5:3 slot rather than its full square: the crisp wordmark
+ * only ever needs the middle band, and the orb and shapes overflow symmetrically
+ * into the beat's margins while they play, so the footer stays short enough to
+ * sit on one screen together with its link directory.
+ *
+ * Rendering budget: the server renders the resting frame (crisp wordmark) so
+ * the mark is in the HTML with zero layout shift and needs no JS to look
+ * finished. The `requestAnimationFrame` loop only runs while the stage is in
+ * the viewport, writes attributes straight to the SVG (no React render per
+ * frame), and never starts under `prefers-reduced-motion`, where the wordmark
+ * simply stays put.
+ */
+export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) {
+ const svgRef = useRef(null)
+ const id = useId().replace(/[^a-zA-Z0-9-]/g, '')
+ const gooId = `fwl-goo-${id}`
+ const inkId = `fwl-ink-${id}`
+ const wordmarkInkId = `fwl-wm-ink-${id}`
+ const clipId = `fwl-clip-${id}`
+ const windowId = `fwl-window-${id}`
+
+ useEffect(() => {
+ const svg = svgRef.current
+ if (!svg) return
+ const blur = svg.querySelector('[data-goo]')
+ if (!blur) return
+
+ const stages: StageNode[] = Array.from(
+ svg.querySelectorAll('[data-stage]'),
+ (el) => ({ el, key: el.getAttribute('data-stage') as StageKey })
+ )
+ const anims: AnimatedNode[] = []
+ for (const el of svg.querySelectorAll('[data-anim]')) {
+ const stage = el.closest('[data-stage]')?.getAttribute('data-stage') as ShapeKey | undefined
+ if (stage) anims.push({ el, track: TRACKS[el.getAttribute('data-anim') as AnimKey], stage })
+ }
+
+ const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')
+ let frame: number | null = null
+ let previous: number | null = null
+ let elapsed = 0
+ let inView = false
+
+ const tick = (now: number) => {
+ if (previous !== null) elapsed += Math.min(now - previous, MAX_FRAME_STEP)
+ previous = now
+ paintFrame(elapsed % CYCLE_MS, blur, stages, anims)
+ frame = requestAnimationFrame(tick)
+ }
+ const play = () => {
+ if (frame !== null || !inView || reducedMotion?.matches) return
+ previous = null
+ frame = requestAnimationFrame(tick)
+ }
+ const pause = () => {
+ if (frame === null) return
+ cancelAnimationFrame(frame)
+ frame = null
+ }
+ const onMotionPreference = () => {
+ if (reducedMotion?.matches) {
+ pause()
+ elapsed = 0
+ paintFrame(0, blur, stages, anims)
+ } else {
+ play()
+ }
+ }
+ reducedMotion?.addEventListener('change', onMotionPreference)
+
+ let observer: IntersectionObserver | undefined
+ if (typeof IntersectionObserver === 'undefined') {
+ inView = true
+ play()
+ } else {
+ observer = new IntersectionObserver(([entry]) => {
+ inView = entry.isIntersecting
+ if (inView) play()
+ else pause()
+ })
+ observer.observe(svg)
+ }
+
+ return () => {
+ pause()
+ observer?.disconnect()
+ reducedMotion?.removeEventListener('change', onMotionPreference)
+ }
+ }, [])
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/index.ts b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/index.ts
new file mode 100644
index 00000000000..0df08ea7389
--- /dev/null
+++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/index.ts
@@ -0,0 +1 @@
+export { FooterWordmarkLoop } from './footer-wordmark-loop'
diff --git a/apps/sim/app/(landing)/components/footer/components/theme-toggle/index.ts b/apps/sim/app/(landing)/components/footer/components/theme-toggle/index.ts
new file mode 100644
index 00000000000..694671e5d77
--- /dev/null
+++ b/apps/sim/app/(landing)/components/footer/components/theme-toggle/index.ts
@@ -0,0 +1 @@
+export { ThemeToggle } from './theme-toggle'
diff --git a/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx b/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx
new file mode 100644
index 00000000000..15bc6a57c9a
--- /dev/null
+++ b/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx
@@ -0,0 +1,90 @@
+'use client'
+
+import { useId, useSyncExternalStore } from 'react'
+import { cn } from '@sim/emcn'
+import { Moon, Sun } from '@sim/emcn/icons'
+import { useTheme } from 'next-themes'
+
+/**
+ * The two themes on offer, in reading order. `system` is deliberately absent:
+ * the marketing site is light by design and dark is an explicit choice, so the
+ * control is a plain either/or.
+ */
+const OPTIONS = [
+ { value: 'light', label: 'Light theme', Icon: Sun },
+ { value: 'dark', label: 'Dark theme', Icon: Moon },
+] as const
+
+type ThemeOption = (typeof OPTIONS)[number]['value']
+
+const SEGMENT =
+ 'flex size-[22px] items-center justify-center rounded-full transition-colors duration-150 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--text-secondary)] peer-focus-visible:outline-offset-2'
+
+/**
+ * The selected segment is painted by the theme class on ``, not by React
+ * state: next-themes puts that class on the document before first paint, so
+ * the right segment already reads as selected in the server HTML and never
+ * flips after hydration. Each segment carries both states and the `dark:` pair
+ * picks one.
+ */
+const SEGMENT_TONE: Record = {
+ light:
+ 'bg-[var(--surface-active)] text-[var(--text-primary)] dark:bg-transparent dark:text-[var(--text-muted)] dark:hover-hover:text-[var(--text-primary)]',
+ dark: 'text-[var(--text-muted)] hover-hover:text-[var(--text-primary)] dark:bg-[var(--surface-active)] dark:text-[var(--text-primary)]',
+}
+
+const subscribeToNothing = () => () => {}
+
+/**
+ * `true` once hydrated, `false` in server HTML and during hydration - the
+ * store-backed form React reconciles without a mismatch, unlike an effect that
+ * flips state after mount.
+ */
+function useHydrated(): boolean {
+ return useSyncExternalStore(
+ subscribeToNothing,
+ () => true,
+ () => false
+ )
+}
+
+/**
+ * Footer light/dark switch: a hairline pill holding a sun and a moon, with the
+ * current theme's segment filled. Picking a segment writes the visitor's
+ * choice through next-themes (`sim-theme`), the same store the workspace
+ * reads, so the theme carries from the marketing site into the app.
+ *
+ * Native radios provide a single tab stop and arrow-key selection. Checked
+ * state waits for hydration because the server cannot read the stored theme;
+ * the visual selection already follows the document theme before hydration.
+ */
+export function ThemeToggle() {
+ const groupName = useId()
+ const { resolvedTheme, setTheme } = useTheme()
+ const hydrated = useHydrated()
+
+ return (
+